The registration-log sheet only has in-game nicknames, so matching Discord
users to teams failed for ~10 of 40 teams. Fienta collects each competitor's
Discord username (+ sometimes user ID) and team name per ticket, giving a
reliable Discord-identity -> team mapping (validated: 201 usernames, 42 teams).
- core/fienta.py: token-auth client; fetch /events/{id}/tickets?attendees=true,
parse competitor/coach/substitute tickets into {username|id -> team} and
{team -> game}; exclude visitor/supporter/LAN/early-bird/waiting-list. No-op
when FIENTA_API_TOKEN/FIENTA_EVENT_ID unset.
- member_sync: resolve_team() tries Fienta (id, then username) then the sheet;
all_managed_team_names() and team_dividers() merge both sources.
- /teamsync + hourly task refresh Fienta alongside the sheet; enabled when
either source is configured.
- config + .env.example: FIENTA_API_TOKEN, FIENTA_EVENT_ID.
- tests: fienta parsing (game detection, inclusion rules, id/username mapping).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPRsW4tazVtYi2jNzVQkre
191 lines
6.9 KiB
Python
191 lines
6.9 KiB
Python
"""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_<SUFFIX>`` 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
|
|
}
|