diff --git a/.env.example b/.env.example index c713385..7f27ca8 100644 --- a/.env.example +++ b/.env.example @@ -10,10 +10,18 @@ DISCORD_TOKEN= SHEET_ID=your-google-sheet-id-here # Separate spreadsheet holding tournament team registrations (Team Name + lineup -# of Discord usernames). Optional; drives /teamsync + hourly team-role sync on -# the economy/community bot. Leave unset to disable team-role sync entirely. +# of Discord usernames). Optional; a FALLBACK source for /teamsync + hourly +# team-role sync on the economy/community bot. Leave unset to disable it. TEAM_SHEET_ID= +# Fienta ticketing - PRIMARY source for team-role sync. The registration collects +# each competitor's Discord username + team name per ticket, so matching is by +# real Discord handle (not game nickname). Get an API token from the Fienta admin +# (organizer settings) and the event's numeric ID from its dashboard URL. Leave +# unset to use only the sheet. +FIENTA_API_TOKEN= +FIENTA_EVENT_ID= + # Where each game's team roles get positioned in the role list. The key suffix # is matched against the section's title row in the sheet ("TipiLAN 2026 CS2 # Registration Log") - every underscore-separated part must appear in it, so diff --git a/.gitignore b/.gitignore index 6b420f5..3e65947 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,7 @@ pocketbase.exe pocketbase pb_data/ pb_migrations/ -logs/ \ No newline at end of file +logs/ +fientalog +fientatickets +fientaorders diff --git a/bot.py b/bot.py index 20392dd..ac13b81 100644 --- a/bot.py +++ b/bot.py @@ -21,7 +21,7 @@ import psutil import config import strings as S -from core import economy, pb_client, sheets +from core import economy, fienta, pb_client, sheets from core.admin import is_bot_admin from core.member_sync import SyncResult, sync_all_team_roles from commands.dev_member_commands import register_dev_member_commands @@ -339,31 +339,35 @@ async def before_birthday_daily(): @tasks.loop(hours=1) async def team_sync_hourly(): - """Reload the tournament registration sheet and re-apply team roles. + """Reload the tournament registration (Fienta + sheet) and re-apply team roles. Economy profile only (the tournament players live in the community guild). Runs the first iteration immediately on start, so this also covers the - initial load at boot. No-op when TEAM_SHEET_ID is unset. + initial load at boot. No-op when neither Fienta nor the team sheet is set. """ - if IS_DEV_PROFILE or not config.TEAM_SHEET_ID: + if IS_DEV_PROFILE or not (config.TEAM_SHEET_ID or config.FIENTA_API_TOKEN): return try: rosters = await sheets.refresh_teams() + fienta_teams = await fienta.refresh_teams() except Exception as e: - log.error("team_sync_hourly: failed to load team sheet: %s", e) + log.error("team_sync_hourly: failed to load team data: %s", e) return - if not rosters: + if not rosters and not fienta_teams: return guild = bot.get_guild(config.GUILD_ID) if guild is None: log.warning("team_sync_hourly: guild %s not found", config.GUILD_ID) return summary = await sync_all_team_roles(guild, log) - if summary.assigned or summary.removed or summary.created or summary.positioned or summary.errors: + if (summary.assigned or summary.removed or summary.created or summary.positioned + or summary.divider_assigned or summary.divider_removed or summary.errors): log.info( - "team_sync_hourly: assigned=%d, removed=%d, created=%d, positioned=%d, errors=%d", + "team_sync_hourly: assigned=%d, removed=%d, created=%d, positioned=%d, " + "divider_assigned=%d, divider_removed=%d, errors=%d", summary.assigned, summary.removed, len(summary.created), - summary.positioned, len(summary.errors), + summary.positioned, summary.divider_assigned, summary.divider_removed, + len(summary.errors), ) for err in summary.errors: log.warning("team_sync_hourly: %s", err) @@ -472,7 +476,8 @@ async def on_ready(): log.info("Birthday daily task started (fires 09:00 Tallinn time)") # Start hourly tournament team-role sync (economy/community guild) - if not IS_DEV_PROFILE and config.TEAM_SHEET_ID and not team_sync_hourly.is_running(): + if (not IS_DEV_PROFILE and (config.TEAM_SHEET_ID or config.FIENTA_API_TOKEN) + and not team_sync_hourly.is_running()): team_sync_hourly.start() log.info("Team-role sync task started (hourly, from the registration sheet)") diff --git a/commands/economy_team_commands.py b/commands/economy_team_commands.py index 77a63d2..2792ecd 100644 --- a/commands/economy_team_commands.py +++ b/commands/economy_team_commands.py @@ -14,7 +14,7 @@ import logging import discord from discord import app_commands -from core import sheets +from core import fienta, sheets from core.admin import bot_admin_check from core.member_sync import sync_all_team_roles import strings as S @@ -38,12 +38,13 @@ def register_economy_team_commands( try: rosters = await sheets.refresh_teams() + fienta_teams = await fienta.refresh_teams() except Exception as e: await interaction.followup.send( S.TEAMSYNC_UI["refresh_error"].format(error=e), ephemeral=True ) return - if not rosters: + if not rosters and not fienta_teams: await interaction.followup.send(S.TEAMSYNC_UI["disabled"], ephemeral=True) return diff --git a/config.py b/config.py index 993bbd4..d3d969d 100644 --- a/config.py +++ b/config.py @@ -28,6 +28,13 @@ SHEET_ID = os.getenv("SHEET_ID") TEAM_SHEET_ID = os.getenv("TEAM_SHEET_ID") GOOGLE_CREDS_PATH = os.getenv("GOOGLE_CREDS_PATH", "credentials.json") +# Fienta ticketing: the registration collects each competitor's Discord username +# (+ sometimes Discord user ID) and team name per ticket, giving a reliable +# Discord-identity -> team mapping the nickname-only sheet cannot. Primary source +# for team-role sync; the sheet stays as a fallback. Unset -> Fienta is skipped. +FIENTA_API_TOKEN = os.getenv("FIENTA_API_TOKEN", "") +FIENTA_EVENT_ID = os.getenv("FIENTA_EVENT_ID", "") + _LEGACY_GUILD_ID = _env_int("GUILD_ID", 0) GUILD_ID_DEV = _env_int("GUILD_ID_DEV", _LEGACY_GUILD_ID) GUILD_ID_ECONOMY = _env_int("GUILD_ID_ECONOMY", _LEGACY_GUILD_ID) diff --git a/core/fienta.py b/core/fienta.py new file mode 100644 index 0000000..7e5e782 --- /dev/null +++ b/core/fienta.py @@ -0,0 +1,190 @@ +"""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 + } diff --git a/core/member_sync.py b/core/member_sync.py index 282400a..4c096de 100644 --- a/core/member_sync.py +++ b/core/member_sync.py @@ -11,9 +11,32 @@ from zoneinfo import ZoneInfo import discord import config -from . import sheets +from . import fienta, sheets log = logging.getLogger(__name__) + + +def resolve_team(member: discord.Member) -> str | None: + """Team a member is registered on: Fienta first (by ID, then username), + then the sheet by username. Fienta is authoritative; the sheet is fallback.""" + return ( + fienta.get_team_for_userid(member.id) + or fienta.get_team_for_username(member.name) + or sheets.get_team_for_username(member.name) + ) + + +def all_managed_team_names() -> set[str]: + """Union of every team name from Fienta and the sheet - the only role names + team sync ever adds or removes.""" + return fienta.all_team_names() | sheets.all_team_names() + + +def team_dividers() -> dict[str, int]: + """{team -> divider role id} merged from both sources; Fienta wins on overlap.""" + merged = dict(sheets.get_team_dividers()) + merged.update(fienta.get_team_dividers()) + return merged _PLACEHOLDER = {"-", "x", "n/a", "none", "ei"} _TZ = ZoneInfo("Europe/Tallinn") @@ -275,10 +298,10 @@ async def sync_team_role( """ result = TeamSyncResult() - team_name = sheets.get_team_for_username(member.name) - all_teams = sheets.all_team_names() + team_name = resolve_team(member) + all_teams = all_managed_team_names() if not all_teams: - return result # feature switched off (no team sheet loaded) + return result # feature switched off (no Fienta token and no team sheet) desired: discord.Role | None = None if team_name: @@ -319,7 +342,7 @@ async def sync_team_role( # configured divider role (switched game / dropped out). Matched by ID, so # only the roles named in config.TEAM_DIVIDERS are ever touched. divider_ids = set(config.TEAM_DIVIDERS.values()) - want_divider_id = sheets.get_team_dividers().get(team_name) if team_name else None + want_divider_id = team_dividers().get(team_name) if team_name else None want_divider = guild.get_role(want_divider_id) if want_divider_id else None if want_divider is not None and want_divider not in member.roles: @@ -405,8 +428,8 @@ async def apply_team_role_positions( downstream is name-based. Returns ``(roles_moved, errors)``; a no-op returns ``(0, [])``. """ - team_dividers = sheets.get_team_dividers() # {team: divider role ID} - if not team_dividers: + team_divider_ids = team_dividers() # {team: divider role ID}, both sources + if not team_divider_ids: return 0, [] # no dividers configured, or nothing matched errors: list[str] = [] @@ -414,7 +437,7 @@ async def apply_team_role_positions( # that role's current name for the name-based positioning maths below. placements: dict[str, list[str]] = {} resolved: dict[int, discord.Role | None] = {} - for team, divider_id in team_dividers.items(): + for team, divider_id in team_divider_ids.items(): if divider_id not in resolved: resolved[divider_id] = guild.get_role(divider_id) if resolved[divider_id] is None: diff --git a/tests/test_fienta.py b/tests/test_fienta.py new file mode 100644 index 0000000..48356a6 --- /dev/null +++ b/tests/test_fienta.py @@ -0,0 +1,101 @@ +"""Tests for the Fienta ticket parsing that feeds team-role sync. + +Covers the risky bit: turning raw Fienta ticket JSON into a reliable +{discord identity -> team} + {team -> game} mapping, including which ticket +types count as team members and how the game is detected. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +import config # noqa: E402 +from core import fienta # noqa: E402 + + +def _ticket(ttype: str, *, team="", discord="", discord_id="", nick=""): + return { + "order_id": 1, + "rows": [{ + "ticket_type": {"title": ttype}, + "attendee": { + fienta._F_TEAM_NAME: team, + fienta._F_DISCORD_USERNAME: discord, + fienta._F_DISCORD_USERID: discord_id, + fienta._F_DISCORD_USERNAME.replace("discord", "x"): "", + "nickname_134815": nick, + }, + }], + } + + +CS2 = "Counter-Strike 2 Tournament - competitor ticket" +LOL = "League of Legends Tournament - competitor ticket" + + +def test_parse_maps_username_and_userid_to_team(): + fienta.parse_tickets([ + _ticket(CS2, team="KONE", discord="ar7enchik", discord_id="123"), + ]) + assert fienta.get_team_for_username("ar7enchik") == "KONE" + assert fienta.get_team_for_username("AR7ENCHIK") == "KONE" # case-insensitive + assert fienta.get_team_for_userid(123) == "KONE" + assert fienta.get_team_game("KONE") == "CS2" + + +def test_parse_detects_game_from_ticket_type(): + fienta.parse_tickets([ + _ticket(CS2, team="KONE", discord="a"), + _ticket(LOL, team="Ööbik", discord="b"), + ]) + assert fienta.get_team_game("KONE") == "CS2" + assert fienta.get_team_game("Ööbik") == "LoL" + + +def test_parse_excludes_non_player_ticket_types(): + fienta.parse_tickets([ + _ticket("Visitor's Ticket", team="", discord=""), + _ticket("Early Bird - visitor ticket", team="X", discord="ghost"), + _ticket("Counter-Strike 2 Tournament Waiting List", team="WL", discord="waiter"), + _ticket("LAN area - Access Ticket", team="", discord=""), + _ticket(CS2, team="KONE", discord="real"), + ]) + assert fienta.all_team_names() == {"KONE"} + assert fienta.get_team_for_username("ghost") is None + assert fienta.get_team_for_username("waiter") is None + assert fienta.get_team_for_username("real") == "KONE" + + +def test_parse_includes_coach_and_substitute(): + fienta.parse_tickets([ + _ticket("Counter-Strike 2 Coach/Manager - competitor ticket", team="KONE", discord="coach"), + _ticket("CS2 Substitute Player - competitor ticket", team="KONE", discord="sub"), + ]) + assert fienta.get_team_for_username("coach") == "KONE" + assert fienta.get_team_for_username("sub") == "KONE" + + +def test_parse_skips_tickets_without_team_or_discord(): + fienta.parse_tickets([ + _ticket(CS2, team="", discord="noteam"), # no team -> skipped + _ticket(CS2, team="KONE", discord=""), # team but no discord -> team known, no user + ]) + assert fienta.get_team_for_username("noteam") is None + assert "KONE" in fienta.all_team_names() + + +def test_get_team_dividers_maps_via_game(monkeypatch): + monkeypatch.setattr(config, "TEAM_DIVIDERS", {"cs2_2026": 100, "lol_2026": 200}) + fienta.parse_tickets([ + _ticket(CS2, team="KONE", discord="a"), + _ticket(LOL, team="Ööbik", discord="b"), + ]) + assert fienta.get_team_dividers() == {"KONE": 100, "Ööbik": 200} + + +def test_strips_leading_at_from_discord_username(): + fienta.parse_tickets([_ticket(CS2, team="KONE", discord="@handle")]) + assert fienta.get_team_for_username("handle") == "KONE"