"""Fienta ticketing integration - authoritative Discord -> team mapping. The tournament registration on Fienta collects, per competitor ticket, the player's Discord username (and sometimes Discord user ID), their team name (order-level, echoed onto each attendee), and the ticket type (which names the game). That gives a reliable Discord-identity -> team mapping the nickname-only registration sheet cannot, so this is the PRIMARY source for team-role sync, with :mod:`core.sheets` kept as a fallback. Enabled by ``FIENTA_API_TOKEN`` + ``FIENTA_EVENT_ID``; when either is unset all caches stay empty and every getter is a no-op, so team sync silently falls back to the sheet. Only real team members get roles - competitor, coach/manager and substitute tickets are included; visitors, supporters, LAN-access, early-bird and waiting-list tickets are excluded. """ from __future__ import annotations import logging import aiohttp import config log = logging.getLogger(__name__) _API_BASE = "https://fienta.com/api/v1" _PAGE_SIZE = 1000 # Fienta's max per page; one page covers a whole tournament # Ticket-type titles that represent an actual team member who should get a role. _INCLUDED_KEYWORDS = ("competitor", "coach", "manager", "substitute") # ...unless the title also matches one of these (visitors etc. are never players). _EXCLUDED_KEYWORDS = ( "visitor", "supporter", "lan area", "early bird", "waiting list", "waitlist", ) # Attendee custom-field keys. Fienta appends the field id to the machine name; # these come from GET /events/{id}/custom-fields for event 176532. _F_DISCORD_USERNAME = "discord_username_134871" _F_DISCORD_USERID = "discord_user_id_135840" _F_TEAM_NAME = "team_name_134821" _GAME_KEYWORDS: list[tuple[str, tuple[str, ...]]] = [ ("CS2", ("counter-strike", "counter strike", "cs2", "csgo", "cs:go")), ("LoL", ("league of legends", "league", "lol")), ] # Caches, rebuilt by refresh_teams()/parse_tickets(). _by_username: dict[str, str] = {} # discord username (lower) -> team name _by_userid: dict[str, str] = {} # discord user id (str) -> team name _team_game: dict[str, str] = {} # team name -> "CS2" / "LoL" _team_names: set[str] = set() def _norm_username(name: str) -> str: """Normalise a Discord username for matching: lowercased, no leading @.""" return name.strip().lower().lstrip("@") def _detect_game(ticket_type_title: str) -> str | None: """Return the game code for a ticket-type title, or None if unrecognised.""" title = ticket_type_title.lower() for game, keywords in _GAME_KEYWORDS: if any(k in title for k in keywords): return game return None def _is_included(ticket_type_title: str) -> bool: """True when this ticket type is an actual team member (not a visitor etc.).""" title = ticket_type_title.lower() if any(k in title for k in _EXCLUDED_KEYWORDS): return False return any(k in title for k in _INCLUDED_KEYWORDS) def _game_divider_ids() -> dict[str, int]: """Map game code -> divider role id, derived from ``config.TEAM_DIVIDERS``. Reuses the same ``TEAM_DIVIDER_`` role IDs the sheet path uses: a suffix like ``cs2_2026`` contributes its id to game ``CS2``. """ out: dict[str, int] = {} for suffix, rid in config.TEAM_DIVIDERS.items(): parts = suffix.split("_") if any(p in ("cs2", "cs", "csgo") for p in parts): out.setdefault("CS2", rid) if any(p in ("lol", "league") for p in parts): out.setdefault("LoL", rid) return out def parse_tickets(tickets: list[dict]) -> None: """Rebuild the caches from a list of Fienta ticket objects. Pure/synchronous so it can be unit-tested without hitting the API. """ global _by_username, _by_userid, _team_game, _team_names by_username: dict[str, str] = {} by_userid: dict[str, str] = {} team_game: dict[str, str] = {} for ticket in tickets: rows = ticket.get("rows") or [] if not rows: continue row = rows[0] title = (row.get("ticket_type") or {}).get("title", "") if not _is_included(title): continue attendee = row.get("attendee") or {} team = (attendee.get(_F_TEAM_NAME) or "").strip() if not team: continue game = _detect_game(title) # Keep the first non-None game seen for a team (all its tickets agree). team_game[team] = game or team_game.get(team) uname = _norm_username(attendee.get(_F_DISCORD_USERNAME) or "") uid = (attendee.get(_F_DISCORD_USERID) or "").strip() if uname: by_username[uname] = team if uid.isdigit(): by_userid[uid] = team _by_username = by_username _by_userid = by_userid _team_game = team_game _team_names = set(team_game) async def refresh_teams() -> set[str]: """Fetch competitor tickets from Fienta and rebuild the caches. No-op returning an empty set when ``FIENTA_API_TOKEN`` / ``FIENTA_EVENT_ID`` are unset, so the caller transparently falls back to the sheet. """ if not config.FIENTA_API_TOKEN or not config.FIENTA_EVENT_ID: parse_tickets([]) return set() url = f"{_API_BASE}/events/{config.FIENTA_EVENT_ID}/tickets" headers = {"Authorization": f"Bearer {config.FIENTA_API_TOKEN}"} tickets: list[dict] = [] async with aiohttp.ClientSession() as session: page = 1 while True: params = {"attendees": "true", "per_page": str(_PAGE_SIZE), "page": str(page)} async with session.get(url, headers=headers, params=params) as resp: resp.raise_for_status() data = await resp.json() batch = data.get("tickets") or [] tickets.extend(batch) if len(batch) < _PAGE_SIZE: break page += 1 parse_tickets(tickets) log.info( "Fienta: %d tickets -> %d teams, %d discord usernames, %d discord ids", len(tickets), len(_team_names), len(_by_username), len(_by_userid), ) return set(_team_names) def get_team_for_username(username: str) -> str | None: """Team the given Discord username is registered on, or None.""" return _by_username.get(_norm_username(username)) def get_team_for_userid(user_id: int) -> str | None: """Team the given Discord user ID is registered on, or None (IDs are sparse).""" return _by_userid.get(str(user_id)) def all_team_names() -> set[str]: """Every team name seen in the included Fienta tickets.""" return set(_team_names) def get_team_game(team: str) -> str | None: """Game code ("CS2"/"LoL") for a team, or None.""" return _team_game.get(team) def get_team_dividers() -> dict[str, int]: """{team name -> divider role id}, via each team's game and config dividers.""" game_div = _game_divider_ids() return { team: game_div[game] for team, game in _team_game.items() if game and game in game_div }