"""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) # --------------------------------------------------------------------------- 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] = [] 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) -> None: """Verify the header row matches what we expect. 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; column lookups still work as long as the headers we rely on exist. """ existing = ws.row_values(1) if existing != EXPECTED_HEADERS: missing = [h for h in EXPECTED_HEADERS if h not in existing] log.warning( "Sheet header row does not match EXPECTED_HEADERS " "(missing/renamed: %s). Expected %s, found %s. " "Not writing to the (protected) header row; fix it manually if needed.", missing or "none — order/whitespace differs", EXPECTED_HEADERS, existing, ) def _refresh_sync() -> list[dict]: global _cache ws = _get_worksheet() _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 try: col_idx = EXPECTED_HEADERS.index(column_name) + 1 except ValueError: 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 = EXPECTED_HEADERS.index("Discordis synced?") + 1 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() row = [""] * len(EXPECTED_HEADERS) row[EXPECTED_HEADERS.index("Discord")] = username row[EXPECTED_HEADERS.index("User ID")] = str(discord_id) row[EXPECTED_HEADERS.index("Discordis synced?")] = "FALSE" ws.append_row(row, value_input_option="USER_ENTERED") new_entry = {h: row[i] for i, h in enumerate(EXPECTED_HEADERS)} _cache.append(new_entry) 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: title = 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_`` 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()): divider = resolve_divider(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)