diff --git a/.env.example b/.env.example index 485bd09..f6075b3 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,29 @@ DISCORD_TOKEN= # Google Sheets spreadsheet ID (the long string in the sheet URL) SHEET_ID=your-google-sheet-id-here +# Separate spreadsheet holding tournament team registrations (Team Name + lineup +# 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 +# CS2_2026 matches only the 2026 CS2 block while a plain CS2 would match any +# year. The value is the Discord role ID to place those teams under (matching by +# ID means renaming the divider role never breaks positioning). +# Optional: sections that match nothing still get their roles, just unpositioned. +TEAM_DIVIDER_CS2_2026=1498736834656604251 +TEAM_DIVIDER_LOL_2026=1498736949706490017 + # Path to Google service account credentials JSON GOOGLE_CREDS_PATH=credentials.json 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/README.md b/README.md index c82c85c..8291d47 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,9 @@ Discord bot for the TipiLAN community. Manages member roles and nicknames via Go 2. [Member Management](#member-management) 3. [Admin Commands](#admin-commands) 4. [Birthday System](#birthday-system) -5. [TipiCOIN Economy](#tipicoin-economy) -6. [Project Structure](#project-structure) +5. [Tournament Team Roles](#tournament-team-roles) +6. [TipiCOIN Economy](#tipicoin-economy) +7. [Project Structure](#project-structure) --- @@ -107,6 +108,8 @@ cp .env.example .env | `PB_ECONOMY_COLLECTION_DEV` | PocketBase collection used by `BOT_PROFILE=dev` | | `PB_ECONOMY_COLLECTION_ECONOMY` | PocketBase collection used by `BOT_PROFILE=economy` | | `PB_ECONOMY_COLLECTION` | Legacy fallback collection (optional) | +| `TEAM_SHEET_ID` | Tournament registration spreadsheet (separate from the member sheet). Unset = team sync off | +| `TEAM_DIVIDER__` | Divider role that a sheet section's teams get positioned under - see [Tournament team roles](#tournament-team-roles) | ### 6. Install & Run @@ -168,6 +171,7 @@ Admins (bot lacks permission to modify them) are silently skipped and still mark |---|---|---| | `/check` | Manage Roles | Refreshes sheet data, backfills missing User IDs, syncs nicknames + roles for every member, reports stats | | `/member @user` | Manage Roles | Shows a member's full sheet data + calculated age | +| `/teamsync` | Bot admin | Reloads the tournament registration sheet, grants/removes team roles, and repositions them under their dividers. See [Tournament Team Roles](#tournament-team-roles) | | `/sync` | Manage Guild | Re-registers slash commands with Discord | | `/restart` | Manage Guild | Gracefully restarts the bot process; posts ✅ in the same channel when back up | | `/shutdown` | Manage Guild | Shuts the bot down cleanly without restarting | @@ -220,6 +224,46 @@ If a member joins and their birthday is within `BIRTHDAY_WINDOW_DAYS` days, a bi --- +## Tournament Team Roles + +Runs on the **economy** profile only, driven by the separate `TEAM_SHEET_ID` spreadsheet. It is independent of the member roster - players are matched straight from the registration sheet by Discord username, and never need a row in the member sheet. + +Re-runs automatically **every hour**, and on demand via `/teamsync` (admin). + +### What it does + +- Grants each registered player the role named after their team, creating that role if it doesn't exist yet +- Removes team roles they no longer hold (switched teams, dropped out) +- Positions every team role directly beneath its game's divider role +- **Only role names present in the sheet are ever added, removed, or moved** - no unrelated role is at risk + +### Divider placement + +The registration sheet stacks several game sections per tab, each under a merged title row: + +``` +[merged] TipiLAN 2026 CS2 Registration Log +No | Team Name | Lineup (nickname, citizenship) | ... +1 | GENESIS | kapa (EST), neaQ (EST) | ... +``` + +Set one `TEAM_DIVIDER_*` variable per section to say where those teams belong: + +```bash +TEAM_DIVIDER_CS2_2026="====== COUNTER-STRIKE 2 2026 ======" +TEAM_DIVIDER_LOL_2026="===== LEAGUE OF LEGENDS 2026 =====" +``` + +- **The key suffix** is matched against the section's title row. Every underscore-separated part must appear in it as a whole word, so `CS2_2026` matches only the 2026 CS2 block, while a plain `CS2` would match that game in any year. When several keys match, the most specific one (most parts) wins. +- **The value** is the *exact* Discord role name, decoration included. Copy it from Server Settings → Roles. +- Sections matching no key still get their team roles - they just aren't repositioned. + +Teams are ordered alphabetically downwards under their divider. Every sync re-checks placement, so roles that predate this feature get pulled into place on the next run. + +> The bot can only move roles **below its own** role. Drag the TipiBOT role above your dividers, or placement is skipped and reported as an error. + +--- + ## TipiCOIN Economy All economy data is stored in **PocketBase** (`economy_users` collection - see `core/pb_client.py`). The currency is **TipiCOIN** (⬡), displayed as a custom Discord emoji configured in `core/economy/store.py → COIN`. diff --git a/bot.py b/bot.py index 98f0174..d31493b 100644 --- a/bot.py +++ b/bot.py @@ -21,9 +21,9 @@ 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 +from core.member_sync import SyncResult, sync_all_team_roles from commands.dev_member_commands import register_dev_member_commands from commands.dev_member_runtime import handle_member_join, run_birthday_daily from commands.economy_admin_commands import register_economy_admin_commands @@ -35,6 +35,7 @@ from commands.economy_prestige_commands import register_prestige_commands from commands.economy_quests_commands import register_economy_quests_commands from commands.economy_profile_commands import register_economy_profile_commands from commands.economy_support_commands import register_economy_support_commands +from commands.economy_team_commands import register_economy_team_commands from commands.ops_channel_commands import register_ops_channel_commands from commands.ops_admin_commands import register_ops_admin_commands from commands.info_commands import register_info_commands @@ -336,6 +337,47 @@ async def before_birthday_daily(): await bot.wait_until_ready() +@tasks.loop(hours=1) +async def team_sync_hourly(): + """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 neither Fienta nor the team sheet is set. + """ + 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 data: %s", e) + return + 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.divider_assigned or summary.divider_removed or summary.errors): + log.info( + "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, summary.divider_assigned, summary.divider_removed, + len(summary.errors), + ) + for err in summary.errors: + log.warning("team_sync_hourly: %s", err) + + +@team_sync_hourly.before_loop +async def before_team_sync_hourly(): + await bot.wait_until_ready() + + # --------------------------------------------------------------------------- # Daily lottery draw (Tallinn-time DRAW_HOUR:00) # --------------------------------------------------------------------------- @@ -504,6 +546,12 @@ async def on_ready(): lottery_draw_daily.start() log.info("Lottery draw task started (fires %02d:00 Tallinn time)", economy.lottery.DRAW_HOUR) + # Start hourly tournament team-role sync (economy/community guild) + 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)") + # Start rotating rich presence if not _rotate_presence.is_running(): _rotate_presence.start() @@ -571,6 +619,10 @@ if IS_DEV_PROFILE: has_announced_today=_has_announced_today, mark_announced_today=_mark_announced_today, ) +else: + # Tournament team-role sync lives on the economy/community bot, where the + # registered players actually are (see commands/economy_team_commands.py). + register_economy_team_commands(tree, bot, log) register_ops_admin_commands( tree, diff --git a/commands/economy_team_commands.py b/commands/economy_team_commands.py new file mode 100644 index 0000000..2792ecd --- /dev/null +++ b/commands/economy_team_commands.py @@ -0,0 +1,98 @@ +"""Tournament team-role sync for the economy/community guild. + +The tournament participants live in the *economy* (community) guild, not the +internal dev guild, so team-role assignment runs here rather than as part of the +member-roster sync in :mod:`commands.dev_member_commands`. This is deliberately +roster-INDEPENDENT: it matches Discord usernames straight against the separate +registration spreadsheet (``TEAM_SHEET_ID``) and never touches the member sheet. +""" + +from __future__ import annotations + +import logging + +import discord +from discord import app_commands + +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 + + +def register_economy_team_commands( + tree: app_commands.CommandTree, + bot: discord.Client, + log: logging.Logger, +) -> None: + @tree.command(name="teamsync", description=S.CMD["teamsync"]) + @app_commands.guild_only() + @bot_admin_check() + async def cmd_teamsync(interaction: discord.Interaction): + await interaction.response.defer(ephemeral=True) + + guild = interaction.guild + if guild is None: + await interaction.followup.send(S.ERR["guild_only"], ephemeral=True) + return + + 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 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, 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), + ) + + +def _format_summary(summary) -> str: + lines = [ + S.TEAMSYNC_UI["done"], + S.TEAMSYNC_UI["scanned"].format(count=summary.scanned), + S.TEAMSYNC_UI["assigned"].format(count=summary.assigned), + S.TEAMSYNC_UI["removed"].format(count=summary.removed), + ] + if summary.created: + # A team can be created only once, but the same role could surface for + # several members in one run - de-dupe for the report. + unique = list(dict.fromkeys(summary.created)) + 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))) + + text = "\n".join(lines) + + if summary.changes: + shown = summary.changes[:20] + text += "\n\n" + S.TEAMSYNC_UI["changes_header"] + "\n" + "\n".join(shown) + if len(summary.changes) > 20: + text += "\n" + S.TEAMSYNC_UI["changes_more"].format(count=len(summary.changes) - 20) + else: + text += "\n\n" + S.TEAMSYNC_UI["no_changes"] + + return text diff --git a/config.py b/config.py index 89a2cb2..86b0e7a 100644 --- a/config.py +++ b/config.py @@ -23,8 +23,18 @@ DISCORD_TOKEN = ( ) or _LEGACY_DISCORD_TOKEN SHEET_ID = os.getenv("SHEET_ID") +# Separate spreadsheet holding the tournament team registrations (Team Name + +# lineup of Discord usernames). Optional: when unset, team-role sync is a no-op. +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) @@ -75,6 +85,43 @@ def _parse_admin_roles(raw: str) -> dict[int, set[int]]: BOT_ADMIN_ROLES: dict[int, set[int]] = _parse_admin_roles(os.getenv("DISCORD_ADMIN_ROLES", "")) +_TEAM_DIVIDER_PREFIX = "TEAM_DIVIDER_" + + +def _parse_team_dividers() -> dict[str, int]: + """Collect TEAM_DIVIDER_ env vars into {suffix: divider role ID}. + + The suffix says which sheet sections the divider covers, the value is the + Discord role ID their teams get positioned under: + + TEAM_DIVIDER_CS2_2026=1498736834656604251 + + Matching by ID (not name) means renaming the divider role in Discord never + breaks positioning. Every underscore-separated part of the suffix must appear + in the section's title row, so `CS2_2026` matches only "TipiLAN 2026 CS2 + Registration Log" while a plain `CS2` would match that section in any year. + Defining a var is what switches positioning on for those sections; teams + whose section matches nothing are still granted their role, just never moved. + """ + dividers: dict[str, int] = {} + for key, value in os.environ.items(): + if not key.startswith(_TEAM_DIVIDER_PREFIX): + continue + suffix = key[len(_TEAM_DIVIDER_PREFIX):].strip().lower() + raw = value.strip() + if not suffix or not raw: + continue + try: + dividers[suffix] = int(raw) + except ValueError: + raise SystemExit( + f"{key}: expected a Discord role ID (integer), got {raw!r}" + ) + return dividers + + +TEAM_DIVIDERS: dict[str, int] = _parse_team_dividers() + PB_URL = os.getenv("PB_URL", "http://127.0.0.1:8090") PB_ADMIN_EMAIL = os.getenv("PB_ADMIN_EMAIL", "") PB_ADMIN_PASSWORD = os.getenv("PB_ADMIN_PASSWORD", "") diff --git a/core/economy/quests.py b/core/economy/quests.py index 9c02252..5dcd3b0 100644 --- a/core/economy/quests.py +++ b/core/economy/quests.py @@ -32,7 +32,7 @@ QUESTS_DAILY: dict[str, QuestDef] = { "fish2": {"stat": "total_fish_caught", "goal": 2, "coins": 150, "exp": 20}, "crime1": {"stat": "crimes_succeeded", "goal": 1, "coins": 200, "exp": 25}, "earn1000": {"stat": "lifetime_earned", "goal": 1000, "coins": 150, "exp": 20}, - "give200": {"stat": "total_given", "goal": 200, "coins": 100, "exp": 15}, + "give200": {"stat": "total_given", "goal": 200, "coins": 250, "exp": 20}, } QUESTS_WEEKLY: dict[str, QuestDef] = { 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 ff3dd69..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") @@ -48,7 +71,6 @@ class SyncResult: """Tracks what happened during a sync operation.""" nickname_changed: bool = False roles_added: list[str] = field(default_factory=list) - roles_removed: list[str] = field(default_factory=list) birthday_soon: bool = False birthday_today: bool = False not_found: bool = False @@ -57,7 +79,36 @@ class SyncResult: @property def changed(self) -> bool: - return self.nickname_changed or self.roles_added or self.roles_removed + return self.nickname_changed or self.roles_added + + +@dataclass +class TeamSyncResult: + """What happened when syncing one member's tournament team role.""" + 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 or self.divider_added or self.divider_removed) + + +@dataclass +class TeamSyncSummary: + """Aggregate outcome of a whole-guild team-role sync.""" + scanned: int = 0 + assigned: int = 0 + 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) def _format_nickname(full_name: str) -> str: @@ -224,6 +275,272 @@ async def sync_member( return result +async def sync_team_role( + member: discord.Member, + guild: discord.Guild, +) -> TeamSyncResult: + """Give one member their tournament team role from the registration sheet. + + Roster-INDEPENDENT: unlike :func:`sync_member` this does not touch the + internal member sheet at all. It matches the member's Discord username + against the team sheet caches (populated by ``sheets.refresh_teams``) and: + + * 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); + * 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 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 = resolve_team(member) + all_teams = all_managed_team_names() + if not all_teams: + return result # feature switched off (no Fienta token and no team sheet) + + desired: discord.Role | None = None + if team_name: + desired = discord.utils.get(guild.roles, name=team_name) + if desired is None: + try: + desired = await guild.create_role(name=team_name, reason="Team sync: uus tiim") + result.created = team_name + log.info("Created team role %r for %s", team_name, member) + except discord.Forbidden: + result.errors.append(f"Tiimirolli '{team_name}' loomiseks puudub õigus") + except discord.HTTPException as e: + result.errors.append(f"Tiimirolli '{team_name}' loomine ebaõnnestus: {e}") + + # Team roles held but no longer registered for (switched teams / dropped out). + to_remove = [r for r in member.roles if r.name in all_teams and r.name != team_name] + + if desired is not None and desired not in member.roles: + try: + await member.add_roles(desired, reason="Team sync") + result.added = desired.name + except discord.Forbidden: + log.debug("No permission to add team role for %s, skipping", member) + except discord.HTTPException as e: + result.errors.append(f"Tiimirolli viga kasutajale {member}: {e}") + + if to_remove: + try: + await member.remove_roles(*to_remove, reason="Team sync: tiim vahetus") + result.removed = [r.name for r in to_remove] + except discord.Forbidden: + log.debug("No permission to remove team roles for %s, skipping", member) + 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 + + +def plan_team_positions( + ordered_names: list[str], + managed: set[str], + placements: dict[str, list[str]], +) -> dict[str, int]: + """Work out the new position of every team role that needs to move. + + ``ordered_names`` is every role name ascending by Discord position, so index + 0 is the bottom of the role list (@everyone) and the last entry is the top. + ``placements`` maps a divider role name to the team roles that belong under + it. Roles in ``managed`` are never moved (integration-managed roles, and + anything at or above the bot's own role, cannot be repositioned). + + Teams are pulled out of the list and re-inserted immediately below their + divider, sorted so they read alphabetically top-to-bottom in the Discord UI. + Everything else keeps its relative order; a role only appears in the result + when its position actually changed. Roles are matched by name, mirroring the + rest of the team sync - with duplicate role names the lowest one wins. + """ + present = set(ordered_names) + placeable: dict[str, list[str]] = {} + for divider, teams in placements.items(): + if divider not in present or divider in managed: + continue + # Descending here because the list is bottom-up: reversing it renders + # alphabetically downwards from the divider. + block = sorted({t for t in teams if t in present and t not in managed}, reverse=True) + if block: + placeable[divider] = block + if not placeable: + return {} + + movable = {t for block in placeable.values() for t in block} + remaining = [n for n in ordered_names if n not in movable] + for divider, block in placeable.items(): + idx = remaining.index(divider) + remaining[idx:idx] = block + + old_pos = {name: i for i, name in enumerate(ordered_names)} + return { + name: i + for i, name in enumerate(remaining) + if name not in managed and old_pos.get(name) != i + } + + +async def apply_team_role_positions( + guild: discord.Guild, + log: logging.Logger = log, +) -> tuple[int, list[str]]: + """Move every team role directly beneath its configured divider role. + + Driven by the ``TEAM_DIVIDER_*`` config: teams whose sheet section matched + one are placed under that divider role, the rest are left exactly where they + are. Dividers are configured by role ID, so a rename never breaks placement; + the ID is resolved to the role's current name here, and the ordering maths + downstream is name-based. Returns ``(roles_moved, errors)``; a no-op returns + ``(0, [])``. + """ + 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] = [] + # Resolve each configured divider ID to its role once, then key placements by + # 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_divider_ids.items(): + if divider_id not in resolved: + resolved[divider_id] = guild.get_role(divider_id) + if resolved[divider_id] is None: + errors.append(f"Eraldajarolli ID {divider_id} ei leitud serverist") + divider = resolved[divider_id] + if divider is not None: + placements.setdefault(divider.name, []).append(team) + if not placements: + return 0, errors + + by_name: dict[str, discord.Role] = {} + for role in sorted(guild.roles, key=lambda r: r.position): + by_name.setdefault(role.name, role) + + # The bot can only reorder roles strictly below its own highest role. + bot_top = max((r.position for r in guild.me.roles), default=0) + for divider in list(placements): + role = by_name.get(divider) + if role is None: + errors.append(f"Eraldajarolli '{divider}' ei leitud serverist") + placements.pop(divider) + elif role.position >= bot_top: + errors.append(f"Eraldaja '{divider}' on boti rollist kõrgemal - ei saa liigutada") + placements.pop(divider) + if not placements: + return 0, errors + + ordered = sorted(guild.roles, key=lambda r: r.position) + ordered_names = [r.name for r in ordered] + managed = {r.name for r in ordered if r.managed or r.position >= bot_top} + plan = plan_team_positions(ordered_names, managed, placements) + if not plan: + return 0, errors # already in the right place + + positions = {by_name[n]: p for n, p in plan.items() if n in by_name} + try: + await guild.edit_role_positions(positions=positions) + except discord.Forbidden: + errors.append("Tiimirollide järjestamiseks puudub õigus") + 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 + + +async def sync_all_team_roles( + guild: discord.Guild, + log: logging.Logger = log, +) -> TeamSyncSummary: + """Run :func:`sync_team_role` for every human member of ``guild``. + + Assumes the team caches are already fresh (caller runs ``refresh_teams`` + first). Returns an aggregate summary for reporting. + """ + summary = TeamSyncSummary() + for member in guild.members: + if member.bot: + continue + summary.scanned += 1 + res = await sync_team_role(member, guild) + if res.created: + summary.created.append(res.created) + if res.errors: + summary.errors.extend(res.errors) + if res.added: + 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 + # positioned in the same sweep rather than waiting for the next one. + summary.positioned, position_errors = await apply_team_role_positions(guild, log) + summary.errors.extend(position_errors) + return summary + + async def announce_birthday( member: discord.Member, bot: discord.Client, diff --git a/core/sheets.py b/core/sheets.py index cdcbd28..8eba97c 100644 --- a/core/sheets.py +++ b/core/sheets.py @@ -8,6 +8,8 @@ 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 @@ -250,3 +252,248 @@ def _add_new_member_row_sync(username: str, discord_id: int) -> None: 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: + # Accumulate every single-cell row above the header, not just the + # last one: sheets stack the "TipiLAN 2026 CS2" title above notice + # rows ("If a team withdraws..."), and the later notices must not + # clobber the title whose keywords resolve_divider needs. + title = f"{title} {text}".strip() if title else 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()): + # Match on the tab name too ("CS2"/"LoL"): it names the game reliably + # even when the game/year title row is shadowed by notice rows, while + # the section title still supplies the year for year-scoped dividers. + divider = resolve_divider(f"{ws.title} {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) diff --git a/docs/DEV_NOTES.md b/docs/DEV_NOTES.md index 7f3eb39..1742192 100644 --- a/docs/DEV_NOTES.md +++ b/docs/DEV_NOTES.md @@ -19,7 +19,7 @@ The codebase is split into **`core/`** (domain logic), **`commands/`** (Discord | `core/economy/` | Economy business logic **package**, re-exported via `core/economy/__init__.py` so callers use `from core import economy` + attribute access (`economy.do_daily`, `economy.SHOP`, ...). Submodules: `store.py` (user records, per-user locks, `COOLDOWNS`, `ITEM_COOLDOWNS`/`effective_cooldown`, `JAIL_DURATION`, `COIN`, `get_user`/`_commit`/`_txn`, `pending_wager` escrow + `reconcile_pending_wagers`), `income.py`, `gambling.py`, `fishing.py`, `jail.py`, `heist.py`, `prestige.py`, `shop.py`, `consumables.py` (timed buffs + `grant_buff`), `vanity.py` (cosmetic badges), `lootbox.py` (mystery box), `bank.py` (rob-proof vault), `achievements.py` (milestone badges), `lottery.py` (daily draw), `levels.py`, `quests.py`, `leaderboards.py` (incl. `get_all_leaderboards`/`get_economy_stats`, net-worth = balance+bank), `house.py`, `admin.py` | | `core/pb_client.py` | Async PocketBase REST client - auth token cache, CRUD on `economy_users` collection | | `core/sheets.py` | Google Sheets integration (member sync) | -| `core/member_sync.py` | Birthday/member sync helpers | +| `core/member_sync.py` | Birthday/member sync helpers, plus tournament team-role sync + divider placement | ### `commands/` - one slash-command group per file @@ -148,6 +148,37 @@ Gambling EXP is bet-scaled via `gamble_exp(bet)`; fish EXP is per-species in `FI --- +## Tournament Team Roles + +Economy profile only. Roster-independent: matches Discord usernames straight against `TEAM_SHEET_ID`, never the member sheet. Entry points are `/teamsync` (`commands/economy_team_commands.py`) and the hourly `team_sync_hourly` task in `bot.py`; both call `sheets.refresh_teams()` then `member_sync.sync_all_team_roles()`. + +### Pipeline + +| Step | Where | Notes | +|---|---|---| +| Split a tab into per-game blocks | `sheets.parse_team_sections` | Returns `TeamSection(title, rosters)`. The merged title row above each header is the **only** thing identifying game + year | +| Flatten to `{team: [players]}` | `sheets.parse_team_rosters` | Thin wrapper over `parse_team_sections` | +| Section title → divider role name | `sheets.resolve_divider` | Matches `config.TEAM_DIVIDERS`; most specific key wins | +| Cache | `sheets._refresh_teams_sync` | Populates `_team_roster`, `_team_by_username`, `_team_names`, `_team_divider` | +| Grant/remove roles per member | `member_sync.sync_team_role` | Only names in `all_team_names()` are ever touched | +| Compute new role positions | `member_sync.plan_team_positions` | **Pure** - list of names ascending + managed set + placements → `{name: new position}`. Unit-tested without Discord | +| Apply | `member_sync.apply_team_role_positions` | One `guild.edit_role_positions` call per sync | + +### Adding a game to the divider config + +1. `.env` - add `TEAM_DIVIDER__=""`. No code change; `config._parse_team_dividers` discovers any var with that prefix at startup. +2. Restart the bot (env is read once at import). + +### Position maths gotchas + +- Discord positions are **ascending from the bottom** (`@everyone` = 0), so "under the divider in the UI" means a *lower* number. `plan_team_positions` therefore inserts each block reverse-sorted. +- The bot can only reorder roles strictly below its own highest role. Dividers at or above it are skipped with an error rather than attempted. +- `managed` roles (integration/bot/booster) are never emitted in the plan. +- Roles are matched **by name** throughout the feature; with duplicate names the lowest-positioned one wins. +- Only roles whose position actually changed are submitted, so a settled guild costs zero API calls. + +--- + ## Role Hierarchy (Discord) Order top to bottom in server roles: diff --git a/strings/__init__.py b/strings/__init__.py index 0315bb4..7a089c1 100644 --- a/strings/__init__.py +++ b/strings/__init__.py @@ -44,6 +44,7 @@ from .member import ( BIRTHDAY_UI, BIRTHDAY_MONTHS, CHECK_UI, + TEAMSYNC_UI, ) from .economy import ( @@ -144,6 +145,7 @@ __all__ = [ 'BIRTHDAY_UI', 'BIRTHDAY_MONTHS', 'CHECK_UI', + 'TEAMSYNC_UI', 'WORK_JOBS', 'BEG_LINES', 'BEG_JAIL_LINES', diff --git a/strings/commands.py b/strings/commands.py index 83b68c5..09dd895 100644 --- a/strings/commands.py +++ b/strings/commands.py @@ -24,6 +24,7 @@ CMD: dict[str, str] = { "check": "Laadi andmed, täida ID'd ja sünkroniseeri kõik liikmed", "sync": "Sünkroniseeri käsklused Discordi serveriga", "member": "Näita liikme andmeid tabelist", + "teamsync": "[Admin] Sünkroniseeri tiimirollid registreerimistabelist", "restart": "Tee taaskäivitus botile", "shutdown": "Lülita bot välja (ilma taaskäivituseta)", "pause": "Peata / jätka kõik käsklused (hooldusrežiim)", diff --git a/strings/member.py b/strings/member.py index edcfbbb..8b73935 100644 --- a/strings/member.py +++ b/strings/member.py @@ -11,6 +11,7 @@ __all__ = [ 'BIRTHDAY_UI', 'BIRTHDAY_MONTHS', 'CHECK_UI', + 'TEAMSYNC_UI', ] # --------------------------------------------------------------------------- @@ -94,3 +95,25 @@ CHECK_UI: dict[str, str] = { "detail_changed": "🔧 **{name}**: {parts}", "ids_filled": "\n🔑 Täideti **{count}** puuduvat kasutaja ID-d.", } + + +# --------------------------------------------------------------------------- +# /teamsync UI strings (tournament team-role sync from the registration sheet) +# --------------------------------------------------------------------------- + +TEAMSYNC_UI: dict[str, str] = { + "disabled": "⚠️ Tiimide sünkroonimine on välja lülitatud (TEAM_SHEET_ID puudub).", + "refresh_error": "⚠️ Registreerimistabeli laadimine ebaõnnestus: {error}", + "done": "**Tiimide sünkroonimine lõpetatud!**", + "scanned": "👥 Kontrollitud liikmeid: {count}", + "assigned": "✅ Tiimirolle antud: {count}", + "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:**", + "changes_more": "... ja {count} rohkem", +} 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 new file mode 100644 index 0000000..642f415 --- /dev/null +++ b/tests/test_team_sync.py @@ -0,0 +1,383 @@ +"""Tests for team-role sync from the tournament registration sheet. + +Covers the risky parsing (turning a messy, multi-section, merged-cell sheet +into {team: [discord usernames]}) and the roster-independent add/remove/ +auto-create behaviour of sync_team_role, using lightweight fakes for discord ++ the sheets cache. +""" + +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 member_sync, sheets # noqa: E402 +from tests.conftest import run # noqa: E402 + + +# --- parse_lineup ---------------------------------------------------------- + +def test_parse_lineup_strips_citizenship_and_splits(): + cell = "TFT (EST), nqmm (EST), sn1rk (EST), Kevka (EST), Kalatexx (EST)" + assert sheets.parse_lineup(cell) == ["TFT", "nqmm", "sn1rk", "Kevka", "Kalatexx"] + + +def test_parse_lineup_keeps_names_containing_commas_and_semicolons(): + # A single player's descriptive name contains a ';' - must stay one name. + cell = "Onu Klaus ; vahepeal ka mõni teine tegelane (EST), Lurban (EST)" + assert sheets.parse_lineup(cell) == [ + "Onu Klaus ; vahepeal ka mõni teine tegelane", + "Lurban", + ] + + +def test_parse_lineup_handles_odd_usernames(): + cell = "-acc +vac (EST), m (EST), M1X3RRRRRR (EST)" + assert sheets.parse_lineup(cell) == ["-acc +vac", "m", "M1X3RRRRRR"] + + +def test_parse_lineup_mixed_citizenship(): + cell = "imp (LAT), milteg (EST), Freesies (EST)" + assert sheets.parse_lineup(cell) == ["imp", "milteg", "Freesies"] + + +def test_parse_lineup_empty(): + assert sheets.parse_lineup("") == [] + assert sheets.parse_lineup(" ") == [] + + +def test_parse_lineup_fallback_without_citizenship(): + assert sheets.parse_lineup("alice, bob") == ["alice", "bob"] + + +# --- parse_team_rosters (multi-section sheet) ------------------------------ + +# Mirrors the real sheet: merged title rows, a header row, team rows, a blank +# separator, then a SECOND section with a different column count. +SHEET_ROWS = [ + ["", "", "", "", "", "", ""], + ["name", "members", "vrs ranking", "registration_date", "game", "", ""], + ["[merged] TipiLAN 2026 CS2 Registration Log"] + [""] * 6, + ["No", "Team Name", "Lineup (nickname, citizenship)", "VRS Ranking", + "Registration Timestamp", "Status", "Participation confirmed?"], + ["1", "Piirivalvurid", "TFT (EST), nqmm (EST)", "N/A", "01.05.2026 15:07", "Confirmed", "Yes"], + ["2", "GENESIS", "kapa (EST), neaQ (EST)", "N/A", "01.05.2026 15:24", "Confirmed", "Yes"], + ["", "", "", "", "", "", ""], + ["[merged] TipiLAN 2026 LoL Registration Log"] + [""] * 4, + ["No", "Team Name", "Lineup (nickname, citizenship)", + "Registration Timestamp (dd.mm.yyyy hh:mm)", "Confirmation Status"], + ["1", "Pushing 30s", "Onu Klaus (EST), Lurban (EST)", "01.05.2026 21:40", ""], + ["", "", "", "", ""], +] + + +def test_parse_team_rosters_multiple_sections(): + rosters = sheets.parse_team_rosters(SHEET_ROWS) + assert rosters == { + "Piirivalvurid": ["TFT", "nqmm"], + "GENESIS": ["kapa", "neaQ"], + "Pushing 30s": ["Onu Klaus", "Lurban"], + } + + +def test_parse_team_rosters_ignores_non_table_content(): + # No header row anywhere -> nothing extracted, no crash. + assert sheets.parse_team_rosters([["just", "some", "prose"], ["more"]]) == {} + + +# --- parse_team_sections (which game/year a block of teams came from) ------ + +def test_parse_team_sections_tags_each_block_with_its_title(): + sections = sheets.parse_team_sections(SHEET_ROWS) + assert [(s.title, sorted(s.rosters)) for s in sections] == [ + ("[merged] TipiLAN 2026 CS2 Registration Log", ["GENESIS", "Piirivalvurid"]), + ("[merged] TipiLAN 2026 LoL Registration Log", ["Pushing 30s"]), + ] + + +def test_parse_team_sections_title_does_not_leak_to_untitled_block(): + rows = [ + ["[merged] TipiLAN 2026 CS2 Registration Log"] + [""] * 2, + ["No", "Team Name", "Lineup (nickname)"], + ["1", "GENESIS", "kapa (EST)"], + ["", "", ""], + ["No", "Team Name", "Lineup (nickname)"], # second block, no title above it + ["1", "Nameless", "someone (EST)"], + ] + titles = [s.title for s in sheets.parse_team_sections(rows)] + assert titles == ["[merged] TipiLAN 2026 CS2 Registration Log", ""] + + +# --- resolve_divider (section title -> configured divider role) ------------ + +DIVIDERS = { + "cs2_2026": 1498736834656604251, + "lol_2026": 1498736949706490017, +} + + +def test_resolve_divider_matches_game_and_year(): + assert sheets.resolve_divider( + "[merged] TipiLAN 2026 CS2 Registration Log", DIVIDERS + ) == 1498736834656604251 + assert sheets.resolve_divider( + "[merged] TipiLAN 2026 LoL Registration Log", DIVIDERS + ) == 1498736949706490017 + + +def test_resolve_divider_year_scoped_key_ignores_other_years(): + assert sheets.resolve_divider("TipiLAN 2025 CS2 Registration Log", DIVIDERS) is None + + +def test_resolve_divider_prefers_the_most_specific_match(): + dividers = {"cs2": 111, "cs2_2026": 222} + assert sheets.resolve_divider("TipiLAN 2026 CS2 Log", dividers) == 222 + assert sheets.resolve_divider("TipiLAN 2025 CS2 Log", dividers) == 111 + + +def test_resolve_divider_no_config_or_no_title_is_none(): + assert sheets.resolve_divider("TipiLAN 2026 CS2 Log", {}) is None + assert sheets.resolve_divider("", DIVIDERS) is None + + +def test_title_survives_notice_rows_below_it(): + # Real sheets stack the game/year title above single-cell notice rows; the + # title's keywords must survive so resolve_divider still matches (regression: + # notice rows used to overwrite the title, yielding a None divider). + rows = [ + ["TipiLAN 2026 CS2 Registration Log"] + [""] * 4, + ["This log is updated automatically."] + [""] * 4, + ["If a team from the Top 32 withdraws, ..."] + [""] * 4, + ["No", "Team Name", "Lineup (nickname, citizenship)", "", ""], + ["1", "GENESIS", "kapa (EST)", "", ""], + ] + section = sheets.parse_team_sections(rows)[0] + assert "CS2" in section.title and "2026" in section.title + assert sheets.resolve_divider(section.title, DIVIDERS) == 1498736834656604251 + + +# --- plan_team_positions (pure role-ordering maths) ------------------------ + +def test_plan_moves_teams_directly_under_their_divider(): + # Ascending = bottom-up, so the Discord UI renders this list reversed. + ordered = ["@everyone", "60hz", "999", "Raid", "CS2-2026", "LOL-2026", "Bot"] + plan = member_sync.plan_team_positions( + ordered, {"Bot"}, {"CS2-2026": ["60hz", "999", "Raid"]} + ) + final = sorted(set(ordered), key=lambda n: plan.get(n, ordered.index(n))) + # Teams end up just below the divider, alphabetical downwards in the UI. + assert final == ["@everyone", "Raid", "999", "60hz", "CS2-2026", "LOL-2026", "Bot"] + + +def test_plan_groups_each_divider_separately(): + ordered = ["@everyone", "Alpha", "Pushing 30s", "CS2-2026", "LOL-2026", "Bot"] + plan = member_sync.plan_team_positions( + ordered, + {"Bot"}, + {"CS2-2026": ["Alpha"], "LOL-2026": ["Pushing 30s"]}, + ) + final = sorted(set(ordered), key=lambda n: plan.get(n, ordered.index(n))) + assert final == ["@everyone", "Alpha", "CS2-2026", "Pushing 30s", "LOL-2026", "Bot"] + + +def test_plan_is_a_noop_when_already_positioned(): + ordered = ["@everyone", "Raid", "999", "60hz", "CS2-2026", "Bot"] + assert member_sync.plan_team_positions( + ordered, {"Bot"}, {"CS2-2026": ["60hz", "999", "Raid"]} + ) == {} + + +def test_plan_skips_missing_divider_and_leaves_its_teams_alone(): + ordered = ["@everyone", "60hz", "CS2-2026", "Bot"] + plan = member_sync.plan_team_positions( + ordered, {"Bot"}, {"LOL-2026": ["60hz"]} # divider not in the guild + ) + assert plan == {} + + +def test_plan_never_moves_managed_roles(): + ordered = ["@everyone", "60hz", "CS2-2026", "Nitro", "Bot"] + plan = member_sync.plan_team_positions( + ordered, {"Nitro", "Bot"}, {"CS2-2026": ["60hz", "Nitro"]} + ) + assert "Nitro" not in plan and "Bot" not in plan + + +def test_build_username_index_is_case_insensitive(): + index = sheets.build_username_index({"GENESIS": ["Kapa", "neaQ"]}) + assert index == {"kapa": "GENESIS", "neaq": "GENESIS"} + + +# --- sync_team_role behaviour (roster-independent) ------------------------- + +class FakeRole: + def __init__(self, rid: int, name: str): + self.id = rid + self.name = name + + def __eq__(self, other): + return isinstance(other, FakeRole) and other.id == self.id + + def __hash__(self): + return hash(self.id) + + +class FakeMember: + def __init__(self, uid: int, name: str, roles, bot: bool = False): + self.id = uid + self.name = name + self.display_name = name + self.bot = bot + self.roles = list(roles) + + async def add_roles(self, *roles, reason=None): + self.roles.extend(roles) + + async def remove_roles(self, *roles, reason=None): + self.roles = [r for r in self.roles if r not in roles] + + +class FakeGuild: + def __init__(self, roles, members=None): + self.roles = list(roles) + self.members = list(members or []) + self._next = 9000 + self.created: list[str] = [] + + async def create_role(self, name, reason=None): + self._next += 1 + role = FakeRole(self._next, name) + self.roles.append(role) + 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") + keeper = FakeRole(2, "Member") # not a team role - must be left alone + member = FakeMember(1, "tft", roles=[old_team, keeper]) + guild = FakeGuild([old_team, keeper]) + + monkeypatch.setattr(sheets, "get_team_for_username", + lambda n: "GENESIS" if n.lower() == "tft" else None) + monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS", "OldTeam"}) + + result = run(member_sync.sync_team_role(member, guild)) + + assert result.created == "GENESIS" # auto-created the missing role + assert "GENESIS" in guild.created + assert result.added == "GENESIS" + assert result.removed == ["OldTeam"] # left their previous team + role_names = {r.name for r in member.roles} + assert "GENESIS" in role_names + assert "OldTeam" not in role_names + assert "Member" in role_names # unrelated role untouched + + +def test_sync_uses_existing_team_role(monkeypatch): + genesis = FakeRole(3, "GENESIS") + member = FakeMember(1, "kapa", roles=[]) + guild = FakeGuild([genesis]) + + monkeypatch.setattr(sheets, "get_team_for_username", lambda n: "GENESIS") + monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS"}) + + result = run(member_sync.sync_team_role(member, guild)) + + assert guild.created == [] # did NOT create a duplicate + assert result.created is None + assert result.added == "GENESIS" + 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]) + guild = FakeGuild([old_team]) + + monkeypatch.setattr(sheets, "get_team_for_username", lambda n: None) + monkeypatch.setattr(sheets, "all_team_names", lambda: {"OldTeam"}) + + result = run(member_sync.sync_team_role(member, guild)) + + assert result.removed == ["OldTeam"] + assert result.added is None + assert old_team not in member.roles + + +def test_sync_no_team_sheet_is_noop(monkeypatch): + keeper = FakeRole(2, "Member") + member = FakeMember(1, "someone", roles=[keeper]) + guild = FakeGuild([keeper]) + + # Empty caches = feature switched off: no removals even of a stale team role. + monkeypatch.setattr(sheets, "get_team_for_username", lambda n: None) + monkeypatch.setattr(sheets, "all_team_names", lambda: set()) + + result = run(member_sync.sync_team_role(member, guild)) + + assert result.removed == [] + assert guild.created == [] + assert keeper in member.roles + + +def test_sync_all_team_roles_aggregates_and_skips_bots(monkeypatch): + genesis = FakeRole(3, "GENESIS") + m1 = FakeMember(1, "kapa", roles=[]) # will get GENESIS + m2 = FakeMember(2, "nobody", roles=[]) # not registered, unchanged + bot_member = FakeMember(3, "botto", roles=[], bot=True) # skipped + guild = FakeGuild([genesis], members=[m1, m2, bot_member]) + + teams = {"kapa": "GENESIS"} + monkeypatch.setattr(sheets, "get_team_for_username", lambda n: teams.get(n.lower())) + monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS"}) + + summary = run(member_sync.sync_all_team_roles(guild)) + + assert summary.scanned == 2 # bot excluded + assert summary.assigned == 1 + assert summary.removed == 0 + assert summary.changes == ["kapa: +GENESIS"]