forked from sass/tipibot
524 lines
20 KiB
Python
524 lines
20 KiB
Python
"""Google Sheets integration - read/write member data via gspread.
|
|
|
|
Public network-hitting functions are async and delegate the blocking gspread
|
|
work to `asyncio.to_thread` so the discord.py event loop is not stalled
|
|
(stalled loops drop gateway heartbeats and can disconnect the bot).
|
|
Pure-cache helpers (get_cache, find_*) remain sync.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
|
|
import gspread
|
|
from google.oauth2.service_account import Credentials
|
|
|
|
import config
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
# Scopes needed: read + write to Sheets
|
|
SCOPES = [
|
|
"https://www.googleapis.com/auth/spreadsheets",
|
|
"https://www.googleapis.com/auth/drive",
|
|
]
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Expected sheet columns (header row):
|
|
# Nimi | Organisatsioon | Meil | Discord | User ID | Sünnipäev |
|
|
# Telefon | Valdkond | Roll | Discordis synced? | Groupi lisatud?
|
|
#
|
|
# - Nimi : member's real name (used as Discord nickname)
|
|
# - Organisatsioon : organisation value - maps to a Discord role
|
|
# - Meil : email address (read-only for bot)
|
|
# - Discord : Discord username for initial matching
|
|
# - User ID : numeric Discord user ID (bot can populate this)
|
|
# - Sünnipäev : birthday date string (YYYY-MM-DD or MM-DD)
|
|
# - Telefon : phone number (read-only for bot)
|
|
# - Valdkond : field/area value - maps to a Discord role
|
|
# - Roll : role value - maps to a Discord role
|
|
# - Discordis synced? : TRUE/FALSE - bot writes this after confirming sync
|
|
# - Groupi lisatud? : group membership flag (managed externally)
|
|
#
|
|
# The owner adds/reorders columns freely (e.g. "Käepael" was inserted after
|
|
# "Nimi"), so NEVER derive a write position from EXPECTED_HEADERS - resolve it
|
|
# from the live header row via _col_index(). Positional writes used to land one
|
|
# column to the left and overwrote "Roll" with the TRUE/FALSE synced flag.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
EXPECTED_HEADERS = [
|
|
"Nimi",
|
|
"Organisatsioon",
|
|
"Meil",
|
|
"Discord",
|
|
"User ID",
|
|
"Sünnipäev",
|
|
"Telefon",
|
|
"Valdkond",
|
|
"Roll",
|
|
"Discordis synced?",
|
|
"Groupi lisatud?",
|
|
]
|
|
|
|
_client: gspread.Client | None = None
|
|
_worksheet: gspread.Worksheet | None = None
|
|
|
|
# In-memory cache: list of dicts (one per row)
|
|
_cache: list[dict] = []
|
|
|
|
# Live header row (row 1) as last read from the sheet; source of truth for column positions
|
|
_headers: list[str] = []
|
|
|
|
|
|
def _get_worksheet() -> gspread.Worksheet:
|
|
"""Authenticate and return the first worksheet of the configured sheet."""
|
|
global _client, _worksheet
|
|
creds = Credentials.from_service_account_file(config.GOOGLE_CREDS_PATH, scopes=SCOPES)
|
|
_client = gspread.authorize(creds)
|
|
spreadsheet = _client.open_by_key(config.SHEET_ID)
|
|
_worksheet = spreadsheet.sheet1
|
|
return _worksheet
|
|
|
|
|
|
def _ensure_headers(ws: gspread.Worksheet) -> list[str]:
|
|
"""Read the live header row and warn if a column the bot relies on is missing.
|
|
|
|
The production sheet is owner-managed and its header row (row 1) is a
|
|
protected range, so the bot must NOT write to it — attempting to do so
|
|
raises `APIError [400]: You are trying to edit a protected cell or object`
|
|
and aborts the whole refresh. We only log a mismatch so it can be fixed
|
|
by hand. Extra or reordered columns are fine: reads map by header name and
|
|
writes resolve positions from the returned row.
|
|
"""
|
|
existing = ws.row_values(1)
|
|
missing = [h for h in EXPECTED_HEADERS if h not in existing]
|
|
if missing:
|
|
log.warning(
|
|
"Sheet header row is missing expected columns %s (found %s). "
|
|
"Not writing to the (protected) header row; fix it manually.",
|
|
missing,
|
|
existing,
|
|
)
|
|
return existing
|
|
|
|
|
|
def _col_index(ws: gspread.Worksheet, column_name: str) -> int | None:
|
|
"""1-based position of `column_name` in the live header row, or None if absent."""
|
|
global _headers
|
|
if not _headers:
|
|
_headers = _ensure_headers(ws)
|
|
try:
|
|
return _headers.index(column_name) + 1
|
|
except ValueError:
|
|
log.error("Column %r not in sheet header row; refusing to write", column_name)
|
|
return None
|
|
|
|
|
|
def _refresh_sync() -> list[dict]:
|
|
global _cache, _headers
|
|
ws = _get_worksheet()
|
|
_headers = _ensure_headers(ws)
|
|
# head=1: row 1 is the header; row 2 is a formula/stats row - skip it
|
|
records = ws.get_all_records(head=1)
|
|
_cache = records[1:] # drop the formula row (row 2) from the cache
|
|
return _cache
|
|
|
|
|
|
async def refresh() -> list[dict]:
|
|
"""Pull all rows from the sheet into the in-memory cache (non-blocking)."""
|
|
return await asyncio.to_thread(_refresh_sync)
|
|
|
|
|
|
def get_cache() -> list[dict]:
|
|
"""Return the current in-memory cache without re-querying."""
|
|
return _cache
|
|
|
|
|
|
def find_member_by_id(discord_id: int) -> dict | None:
|
|
"""Look up a member row by Discord user ID."""
|
|
for row in _cache:
|
|
raw = row.get("User ID", "")
|
|
if str(raw).strip() == str(discord_id):
|
|
return row
|
|
return None
|
|
|
|
|
|
def find_member_by_username(username: str) -> dict | None:
|
|
"""Look up a member row by Discord username (case-insensitive)."""
|
|
for row in _cache:
|
|
if str(row.get("Discord", "")).strip().lower() == username.lower():
|
|
return row
|
|
return None
|
|
|
|
|
|
def find_member(discord_id: int, username: str) -> dict | None:
|
|
"""Try ID first, fall back to username."""
|
|
return find_member_by_id(discord_id) or find_member_by_username(username)
|
|
|
|
|
|
# ---- Write helpers --------------------------------------------------------
|
|
|
|
def _row_index_for_member(discord_id: int | None = None, username: str | None = None) -> int | None:
|
|
"""Return the 1-based sheet row index for a member (header = row 2, data from row 3)."""
|
|
for idx, row in enumerate(_cache):
|
|
if discord_id and str(row.get("User ID", "")).strip() == str(discord_id):
|
|
return idx + 3 # +3 because header is row 2, data starts row 3, idx is 0-based
|
|
if username and str(row.get("Discord", "")).strip().lower() == username.lower():
|
|
return idx + 3
|
|
return None
|
|
|
|
|
|
def _update_cell_for_member_sync(
|
|
discord_id: int | None,
|
|
username: str | None,
|
|
column_name: str,
|
|
value: str,
|
|
) -> bool:
|
|
ws = _worksheet or _get_worksheet()
|
|
row_idx = _row_index_for_member(discord_id=discord_id, username=username)
|
|
if row_idx is None:
|
|
return False
|
|
|
|
col_idx = _col_index(ws, column_name)
|
|
if col_idx is None:
|
|
return False
|
|
|
|
ws.update([[value]], gspread.utils.rowcol_to_a1(row_idx, col_idx),
|
|
value_input_option="USER_ENTERED")
|
|
|
|
cache_idx = row_idx - 3
|
|
if 0 <= cache_idx < len(_cache):
|
|
_cache[cache_idx][column_name] = value
|
|
|
|
return True
|
|
|
|
|
|
async def update_cell_for_member(
|
|
discord_id: int | None,
|
|
username: str | None,
|
|
column_name: str,
|
|
value: str,
|
|
) -> bool:
|
|
"""Write a value to a specific column for a member row (non-blocking)."""
|
|
return await asyncio.to_thread(
|
|
_update_cell_for_member_sync, discord_id, username, column_name, value
|
|
)
|
|
|
|
|
|
def _batch_set_synced_sync(updates: list[tuple[int, bool]]) -> None:
|
|
ws = _worksheet or _get_worksheet()
|
|
col_idx = _col_index(ws, "Discordis synced?")
|
|
if col_idx is None:
|
|
return
|
|
cells = []
|
|
for discord_id, synced in updates:
|
|
row_idx = _row_index_for_member(discord_id=discord_id)
|
|
if row_idx is None:
|
|
continue
|
|
cells.append(gspread.Cell(row_idx, col_idx, "TRUE" if synced else "FALSE"))
|
|
cache_idx = row_idx - 3
|
|
if 0 <= cache_idx < len(_cache):
|
|
_cache[cache_idx]["Discordis synced?"] = "TRUE" if synced else "FALSE"
|
|
if cells:
|
|
ws.update_cells(cells, value_input_option="USER_ENTERED")
|
|
|
|
|
|
async def batch_set_synced(updates: list[tuple[int, bool]]) -> None:
|
|
"""Batch-write 'Discordis synced?' for multiple members (non-blocking)."""
|
|
await asyncio.to_thread(_batch_set_synced_sync, updates)
|
|
|
|
|
|
async def set_user_id(username: str, discord_id: int) -> bool:
|
|
"""Write a Discord user ID for a row matched by Discord username."""
|
|
return await update_cell_for_member(
|
|
discord_id=None,
|
|
username=username,
|
|
column_name="User ID",
|
|
value=str(discord_id),
|
|
)
|
|
|
|
|
|
async def set_synced(discord_id: int, synced: bool) -> bool:
|
|
"""Mark a member as synced (TRUE) or not (FALSE)."""
|
|
return await update_cell_for_member(
|
|
discord_id=discord_id,
|
|
username=None,
|
|
column_name="Discordis synced?",
|
|
value="TRUE" if synced else "FALSE",
|
|
)
|
|
|
|
|
|
async def update_username(discord_id: int, new_username: str) -> bool:
|
|
"""Update the Discord column for a member (keeps sheet in sync with Discord)."""
|
|
return await update_cell_for_member(
|
|
discord_id=discord_id,
|
|
username=None,
|
|
column_name="Discord",
|
|
value=new_username,
|
|
)
|
|
|
|
|
|
def _add_new_member_row_sync(username: str, discord_id: int) -> None:
|
|
ws = _worksheet or _get_worksheet()
|
|
values = {"Discord": username, "User ID": str(discord_id), "Discordis synced?": "FALSE"}
|
|
cols = {name: _col_index(ws, name) for name in values}
|
|
missing = [name for name, col in cols.items() if col is None]
|
|
if missing:
|
|
raise RuntimeError(f"Sheet header row is missing columns {missing}")
|
|
row = [""] * len(_headers)
|
|
for name, col in cols.items():
|
|
row[col - 1] = values[name]
|
|
ws.append_row(row, value_input_option="USER_ENTERED")
|
|
_cache.append(dict(zip(_headers, row)))
|
|
|
|
|
|
async def add_new_member_row(username: str, discord_id: int) -> None:
|
|
"""Append a new row pre-filled with Discord username and User ID (non-blocking)."""
|
|
await asyncio.to_thread(_add_new_member_row_sync, username, discord_id)
|
|
|
|
|
|
# ===========================================================================
|
|
# Team registration sheet (a SEPARATE spreadsheet, config.TEAM_SHEET_ID)
|
|
# ---------------------------------------------------------------------------
|
|
# Unlike the member roster this sheet is NOT a single clean table: it stacks
|
|
# several game sections (CS2, LoL, ...) - each with merged title/description
|
|
# rows, its own header row, and a block of team rows - across one or more tabs.
|
|
# So we read raw cell values (get_all_values) and scan for header rows rather
|
|
# than relying on get_all_records, which requires one rectangular table.
|
|
#
|
|
# Each team's players live in one "Lineup" cell, comma-joined, every nickname
|
|
# suffixed with a citizenship marker like "(EST)". Those nicknames ARE the
|
|
# players' Discord usernames; the citizenship is used only as a delimiter
|
|
# (a nickname may itself contain commas) and then discarded.
|
|
# ===========================================================================
|
|
|
|
# Matches a citizenship marker such as "(EST)" / "(LAT)". Used to split a
|
|
# lineup cell into individual players, then thrown away.
|
|
_CITIZENSHIP_RE = re.compile(r"\(\s*[A-Za-z]{2,4}\s*\)")
|
|
|
|
_TEAM_NAME_HEADER = "team name"
|
|
_LINEUP_HEADER_PREFIX = "lineup"
|
|
|
|
# Team-sheet caches (mirrors the member-roster cache above)
|
|
_team_roster: dict[str, list[str]] = {} # team name -> [Discord username, ...]
|
|
_team_by_username: dict[str, str] = {} # normalized username -> team name
|
|
_team_names: set[str] = set() # universe of all team names
|
|
_team_divider: dict[str, int] = {} # team name -> divider role ID
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TeamSection:
|
|
"""One game's block of teams within a tab, plus the title row above it.
|
|
|
|
The title ("TipiLAN 2026 CS2 Registration Log") is the only thing that says
|
|
which game and year a block belongs to - the header and team rows below it
|
|
carry neither - so it is what :func:`resolve_divider` matches against.
|
|
"""
|
|
title: str = ""
|
|
rosters: dict[str, list[str]] = field(default_factory=dict)
|
|
|
|
|
|
def parse_lineup(cell: str) -> list[str]:
|
|
"""Split one 'Lineup' cell into player nicknames (Discord usernames).
|
|
|
|
Players are delimited by their trailing citizenship marker, e.g.
|
|
'TFT (EST), nqmm (EST), sn1rk (EST)' -> ['TFT', 'nqmm', 'sn1rk']
|
|
Splitting on the marker (not on commas) keeps nicknames that themselves
|
|
contain commas or semicolons intact. Falls back to comma-splitting when a
|
|
cell carries no citizenship markers at all.
|
|
"""
|
|
cell = str(cell).strip()
|
|
if not cell:
|
|
return []
|
|
names: list[str] = []
|
|
last = 0
|
|
matched = False
|
|
for m in _CITIZENSHIP_RE.finditer(cell):
|
|
matched = True
|
|
chunk = cell[last:m.start()].strip().strip(",;").strip()
|
|
if chunk:
|
|
names.append(chunk)
|
|
last = m.end()
|
|
if not matched:
|
|
return [p.strip() for p in cell.split(",") if p.strip()]
|
|
tail = cell[last:].strip().strip(",;").strip() # stray name after last marker
|
|
if tail:
|
|
names.append(tail)
|
|
return names
|
|
|
|
|
|
def _find_col(row: list, matches) -> int | None:
|
|
for idx, cell in enumerate(row):
|
|
if matches(str(cell)):
|
|
return idx
|
|
return None
|
|
|
|
|
|
def _cell(row: list, idx: int) -> str:
|
|
return str(row[idx]) if 0 <= idx < len(row) else ""
|
|
|
|
|
|
def _merged_title(row: list) -> str | None:
|
|
"""Return a row's lone non-empty cell - a merged section title - else None.
|
|
|
|
A fully blank row returns None rather than "", so blank separators between
|
|
sections do not wipe the title we are holding for the next header row.
|
|
"""
|
|
values = [str(c).strip() for c in row if str(c).strip()]
|
|
return values[0] if len(values) == 1 else None
|
|
|
|
|
|
def parse_team_sections(rows: list[list]) -> list[TeamSection]:
|
|
"""Extract each game's block of teams from a tab's raw rows, with its title.
|
|
|
|
Scans for every header row that has both a 'Team Name' and a 'Lineup...'
|
|
column, then reads the rows beneath it (using that section's own column
|
|
positions) until the team-name column goes blank or a new header appears.
|
|
Handles multiple stacked sections with differing layouts in one tab, and
|
|
tags each with the most recent merged title row seen above it.
|
|
"""
|
|
sections: list[TeamSection] = []
|
|
title = ""
|
|
i, n = 0, len(rows)
|
|
while i < n:
|
|
name_col = _find_col(rows[i], lambda c: c.strip().lower() == _TEAM_NAME_HEADER)
|
|
lineup_col = _find_col(rows[i], lambda c: c.strip().lower().startswith(_LINEUP_HEADER_PREFIX))
|
|
if name_col is None or lineup_col is None:
|
|
if (text := _merged_title(rows[i])) is not None:
|
|
# Accumulate every single-cell row above the header, not just the
|
|
# last one: sheets stack the "TipiLAN 2026 CS2" title above notice
|
|
# rows ("If a team withdraws..."), and the later notices must not
|
|
# clobber the title whose keywords resolve_divider needs.
|
|
title = f"{title} {text}".strip() if title else text
|
|
i += 1
|
|
continue
|
|
rosters: dict[str, list[str]] = {}
|
|
i += 1 # move past the header into the data block
|
|
while i < n:
|
|
team = _cell(rows[i], name_col).strip()
|
|
if not team or team.lower() == _TEAM_NAME_HEADER:
|
|
break # blank team-name (or a new header) ends this section
|
|
players = parse_lineup(_cell(rows[i], lineup_col))
|
|
if players:
|
|
rosters.setdefault(team, []).extend(players)
|
|
i += 1
|
|
if rosters:
|
|
sections.append(TeamSection(title=title, rosters=rosters))
|
|
title = "" # consumed - do not leak it onto the next section
|
|
return sections
|
|
|
|
|
|
def parse_team_rosters(rows: list[list]) -> dict[str, list[str]]:
|
|
"""Flatten every section in a tab into {team_name: [nickname, ...]}."""
|
|
rosters: dict[str, list[str]] = {}
|
|
for section in parse_team_sections(rows):
|
|
for team, players in section.rosters.items():
|
|
rosters.setdefault(team, []).extend(players)
|
|
return rosters
|
|
|
|
|
|
def resolve_divider(title: str, dividers: dict[str, int] | None = None) -> int | None:
|
|
"""Return the divider role ID configured for a section title, if any.
|
|
|
|
A ``TEAM_DIVIDER_<SUFFIX>`` entry matches when every underscore-separated
|
|
part of its suffix appears as a whole word in the title, so ``CS2`` matches
|
|
a CS2 section from any year while ``CS2_2026`` matches only the 2026 one.
|
|
The most specific match (most parts) wins, which lets a year-scoped entry
|
|
override a general one for the same game.
|
|
"""
|
|
if dividers is None:
|
|
dividers = config.TEAM_DIVIDERS
|
|
haystack = title.lower()
|
|
best_id: int | None = None
|
|
best_parts = 0
|
|
for suffix, role_id in dividers.items():
|
|
parts = [p for p in suffix.split("_") if p]
|
|
if not parts or len(parts) <= best_parts:
|
|
continue
|
|
if all(re.search(rf"\b{re.escape(p)}\b", haystack) for p in parts):
|
|
best_id, best_parts = role_id, len(parts)
|
|
return best_id
|
|
|
|
|
|
def build_username_index(rosters: dict[str, list[str]]) -> dict[str, str]:
|
|
"""Invert {team: [usernames]} into {normalized username: team}.
|
|
|
|
If the same username appears on two teams the last one wins and a warning
|
|
is logged (a person is expected to be on exactly one team).
|
|
"""
|
|
index: dict[str, str] = {}
|
|
for team, players in rosters.items():
|
|
for player in players:
|
|
key = player.strip().lower()
|
|
if not key:
|
|
continue
|
|
if key in index and index[key] != team:
|
|
log.warning(
|
|
"Player %r appears on both %r and %r; using %r",
|
|
player, index[key], team, team,
|
|
)
|
|
index[key] = team
|
|
return index
|
|
|
|
|
|
def _refresh_teams_sync() -> dict[str, list[str]]:
|
|
global _team_roster, _team_by_username, _team_names, _team_divider
|
|
creds = Credentials.from_service_account_file(config.GOOGLE_CREDS_PATH, scopes=SCOPES)
|
|
client = gspread.authorize(creds)
|
|
spreadsheet = client.open_by_key(config.TEAM_SHEET_ID)
|
|
|
|
rosters: dict[str, list[str]] = {}
|
|
dividers: dict[str, int] = {}
|
|
for ws in spreadsheet.worksheets():
|
|
for section in parse_team_sections(ws.get_all_values()):
|
|
# Match on the tab name too ("CS2"/"LoL"): it names the game reliably
|
|
# even when the game/year title row is shadowed by notice rows, while
|
|
# the section title still supplies the year for year-scoped dividers.
|
|
divider = resolve_divider(f"{ws.title} {section.title}")
|
|
for team, players in section.rosters.items():
|
|
rosters.setdefault(team, []).extend(players)
|
|
if divider:
|
|
dividers[team] = divider
|
|
|
|
_team_roster = rosters
|
|
_team_by_username = build_username_index(rosters)
|
|
_team_names = set(rosters)
|
|
_team_divider = dividers
|
|
return rosters
|
|
|
|
|
|
async def refresh_teams() -> dict[str, list[str]]:
|
|
"""Reload the team registration sheet into the in-memory team caches.
|
|
|
|
No-op returning {} when TEAM_SHEET_ID is not configured, so the whole
|
|
feature can be left switched off without touching sync behaviour.
|
|
"""
|
|
if not config.TEAM_SHEET_ID:
|
|
return {}
|
|
return await asyncio.to_thread(_refresh_teams_sync)
|
|
|
|
|
|
def get_team_for_username(username: str) -> str | None:
|
|
"""Return the team a Discord username is registered on, or None."""
|
|
return _team_by_username.get(str(username).strip().lower())
|
|
|
|
|
|
def all_team_names() -> set[str]:
|
|
"""Every team name known from the registration sheet (the role universe)."""
|
|
return set(_team_names)
|
|
|
|
|
|
def get_team_rosters() -> dict[str, list[str]]:
|
|
"""Current {team: [usernames]} cache (mainly for diagnostics/tests)."""
|
|
return _team_roster
|
|
|
|
|
|
def get_team_dividers() -> dict[str, int]:
|
|
"""Current {team: divider role ID} cache.
|
|
|
|
Only teams whose section title matched a configured TEAM_DIVIDER_* entry
|
|
appear here, so an empty dict means positioning is switched off.
|
|
"""
|
|
return dict(_team_divider)
|