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 8bf88ca..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,24 +38,28 @@ 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 summary = await sync_all_team_roles(guild, log) await interaction.followup.send(_format_summary(summary), ephemeral=True) log.info( - "/teamsync - scanned=%d, assigned=%d, removed=%d, created=%d, positioned=%d, errors=%d", + "/teamsync - scanned=%d, assigned=%d, removed=%d, created=%d, " + "positioned=%d, divider_assigned=%d, divider_removed=%d, errors=%d", summary.scanned, summary.assigned, summary.removed, len(summary.created), summary.positioned, + summary.divider_assigned, + summary.divider_removed, len(summary.errors), ) @@ -74,6 +78,10 @@ def _format_summary(summary) -> str: lines.append(S.TEAMSYNC_UI["created"].format(roles=", ".join(unique))) if summary.positioned: lines.append(S.TEAMSYNC_UI["positioned"].format(count=summary.positioned)) + if summary.divider_assigned: + lines.append(S.TEAMSYNC_UI["divider_assigned"].format(count=summary.divider_assigned)) + if summary.divider_removed: + lines.append(S.TEAMSYNC_UI["divider_removed"].format(count=summary.divider_removed)) if summary.errors: lines.append(S.TEAMSYNC_UI["errors"].format(count=len(summary.errors))) 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 b8c150f..a579fa4 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") @@ -65,11 +88,13 @@ class TeamSyncResult: added: str | None = None # team role name granted, if any removed: list[str] = field(default_factory=list) # stale team roles taken away created: str | None = None # team role name auto-created in the guild, if any + divider_added: str | None = None # game divider role granted as participant tag + divider_removed: list[str] = field(default_factory=list) # stale divider roles taken away errors: list[str] = field(default_factory=list) @property def changed(self) -> bool: - return bool(self.added or self.removed) + return bool(self.added or self.removed or self.divider_added or self.divider_removed) @dataclass @@ -80,6 +105,8 @@ class TeamSyncSummary: removed: int = 0 created: list[str] = field(default_factory=list) positioned: int = 0 # team roles moved under a divider + divider_assigned: int = 0 # members given their game divider role + divider_removed: int = 0 # stale game divider roles taken away changes: list[str] = field(default_factory=list) # human-readable per-member lines errors: list[str] = field(default_factory=list) @@ -260,18 +287,21 @@ async def sync_team_role( * grants the role for the team they're registered on (auto-creating that role in the guild when it does not exist yet); - * removes any *other* team role they still carry (left / switched teams). + * removes any *other* team role they still carry (left / switched teams); + * grants their game's divider role as a participant tag (and strips any + other configured divider role they still carry, i.e. switched game). - Only role NAMES present in the team sheet are ever added or removed, so no + Only team role NAMES present in the team sheet and the configured divider + role IDs (``config.TEAM_DIVIDERS``) are ever added or removed, so no unrelated role is ever at risk. When ``TEAM_SHEET_ID`` is unset the caches are empty and this is a no-op returning an unchanged result. """ 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: @@ -307,6 +337,35 @@ async def sync_team_role( except discord.HTTPException as e: result.errors.append(f"Tiimirolli eemaldamise viga kasutajale {member}: {e}") + # --- Participant divider role (the game's divider role doubles as a tag) --- + # Grant the divider role for the member's team's game, and strip any other + # 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 = 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: + try: + await member.add_roles(want_divider, reason="Team sync: mänguosaleja") + result.divider_added = want_divider.name + except discord.Forbidden: + log.debug("No permission to add divider role for %s, skipping", member) + except discord.HTTPException as e: + result.errors.append(f"Eraldajarolli viga kasutajale {member}: {e}") + + stale_dividers = [ + r for r in member.roles if r.id in divider_ids and r.id != want_divider_id + ] + if stale_dividers: + try: + await member.remove_roles(*stale_dividers, reason="Team sync: mäng vahetus") + result.divider_removed = [r.name for r in stale_dividers] + except discord.Forbidden: + log.debug("No permission to remove divider roles for %s, skipping", member) + except discord.HTTPException as e: + result.errors.append(f"Eraldajarolli eemaldamise viga kasutajale {member}: {e}") + return result @@ -369,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] = [] @@ -378,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: @@ -421,6 +480,16 @@ async def apply_team_role_positions( return 0, errors except discord.HTTPException as e: errors.append(f"Tiimirollide järjestamine ebaõnnestus: {e}") + # 50013 here despite Manage Roles usually means a role in the batch sits + # at/above the bot's top role. Log the batch vs bot_top to pinpoint it. + log.warning( + "edit_role_positions failed (%s); bot_top=%d; batch=%s", + e, bot_top, + sorted( + ((r.name, r.position, target) for r, target in positions.items()), + key=lambda x: -x[1], + ), + ) return 0, errors log.info("Positioned %d team role(s) under their dividers", len(positions)) return len(positions), errors @@ -449,12 +518,20 @@ async def sync_all_team_roles( summary.assigned += 1 if res.removed: summary.removed += len(res.removed) + if res.divider_added: + summary.divider_assigned += 1 + if res.divider_removed: + summary.divider_removed += len(res.divider_removed) if res.changed: bits: list[str] = [] if res.added: bits.append(f"+{res.added}") if res.removed: bits.append("-" + ", -".join(res.removed)) + if res.divider_added: + bits.append(f"+[{res.divider_added}]") + if res.divider_removed: + bits.append("-[" + "], -[".join(res.divider_removed) + "]") summary.changes.append(f"{member.display_name}: {', '.join(bits)}") # Placement runs after the grant/remove pass so roles created this run are diff --git a/strings/member.py b/strings/member.py index 160c731..8b73935 100644 --- a/strings/member.py +++ b/strings/member.py @@ -110,6 +110,8 @@ TEAMSYNC_UI: dict[str, str] = { "removed": "➖ Tiimirolle eemaldatud: {count}", "created": "🆕 Loodud uusi tiimirolle: {roles}", "positioned": "📍 Eraldaja alla paigutatud: {count}", + "divider_assigned": "🏷️ Mängurolle (eraldaja) antud: {count}", + "divider_removed": "➖ Mängurolle (eraldaja) eemaldatud: {count}", "errors": "⚠️ Vead: {count}", "no_changes": "✨ Kõik tiimirollid olid juba korras.", "changes_header": "**Muudatused:**", 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" diff --git a/tests/test_team_sync.py b/tests/test_team_sync.py index 9dc740a..642f415 100644 --- a/tests/test_team_sync.py +++ b/tests/test_team_sync.py @@ -13,6 +13,7 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent)) +import config # noqa: E402 from core import member_sync, sheets # noqa: E402 from tests.conftest import run # noqa: E402 @@ -253,6 +254,9 @@ class FakeGuild: self.created.append(name) return role + def get_role(self, rid): + return next((r for r in self.roles if r.id == rid), None) + def test_sync_creates_missing_team_role_and_removes_old_one(monkeypatch): old_team = FakeRole(1, "OldTeam") @@ -292,6 +296,43 @@ def test_sync_uses_existing_team_role(monkeypatch): assert genesis in member.roles +def test_sync_grants_game_divider_role(monkeypatch): + genesis = FakeRole(3, "GENESIS") + cs2_div = FakeRole(100, "====== CS2 2026 ======") + member = FakeMember(1, "kapa", roles=[]) + guild = FakeGuild([genesis, cs2_div]) + + monkeypatch.setattr(sheets, "get_team_for_username", lambda n: "GENESIS") + monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS"}) + monkeypatch.setattr(sheets, "get_team_dividers", lambda: {"GENESIS": 100}) + monkeypatch.setattr(config, "TEAM_DIVIDERS", {"cs2_2026": 100, "lol_2026": 200}) + + result = run(member_sync.sync_team_role(member, guild)) + + assert result.divider_added == "====== CS2 2026 ======" + assert cs2_div in member.roles + + +def test_sync_swaps_divider_role_on_game_switch(monkeypatch): + genesis = FakeRole(3, "GENESIS") + cs2_div = FakeRole(100, "CS2") + lol_div = FakeRole(200, "LoL") + member = FakeMember(1, "kapa", roles=[lol_div]) # was LoL, now on a CS2 team + guild = FakeGuild([genesis, cs2_div, lol_div]) + + monkeypatch.setattr(sheets, "get_team_for_username", lambda n: "GENESIS") + monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS"}) + monkeypatch.setattr(sheets, "get_team_dividers", lambda: {"GENESIS": 100}) + monkeypatch.setattr(config, "TEAM_DIVIDERS", {"cs2_2026": 100, "lol_2026": 200}) + + result = run(member_sync.sync_team_role(member, guild)) + + assert result.divider_added == "CS2" + assert result.divider_removed == ["LoL"] + ids = {r.id for r in member.roles} + assert 100 in ids and 200 not in ids + + def test_sync_strips_team_role_when_not_registered(monkeypatch): old_team = FakeRole(1, "OldTeam") member = FakeMember(1, "ghost", roles=[old_team])