diff --git a/.env.example b/.env.example index 0851cee..f810cb1 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,12 @@ SHEET_ID=your-google-sheet-id-here # the economy/community bot. Leave unset to disable team-role sync entirely. TEAM_SHEET_ID= +# Team-section "divider" role IDs. A newly-created team role is positioned +# directly under the divider for its game (CS2 / LoL). Season-specific - update +# yearly. 0 = leave that game's new roles at the bottom of the role list. +CS2_DIVIDER_ROLE_ID= +LOL_DIVIDER_ROLE_ID= + # Path to Google service account credentials JSON GOOGLE_CREDS_PATH=credentials.json diff --git a/commands/economy_team_commands.py b/commands/economy_team_commands.py index 6559c7b..d754cbe 100644 --- a/commands/economy_team_commands.py +++ b/commands/economy_team_commands.py @@ -16,7 +16,7 @@ from discord import app_commands from core import sheets from core.admin import bot_admin_check -from core.member_sync import sync_all_team_roles +from core.member_sync import reposition_team_roles, sync_all_team_roles import strings as S @@ -27,8 +27,9 @@ def register_economy_team_commands( ) -> None: @tree.command(name="teamsync", description=S.CMD["teamsync"]) @app_commands.guild_only() + @app_commands.describe(reposition=S.OPT["teamsync_reposition"]) @bot_admin_check() - async def cmd_teamsync(interaction: discord.Interaction): + async def cmd_teamsync(interaction: discord.Interaction, reposition: bool = False): await interaction.response.defer(ephemeral=True) guild = interaction.guild @@ -48,14 +49,22 @@ def register_economy_team_commands( return summary = await sync_all_team_roles(guild, log) - await interaction.followup.send(_format_summary(summary), ephemeral=True) + message = _format_summary(summary) + + repo = None + if reposition: + repo = await reposition_team_roles(guild, log) + message += "\n\n" + _format_reposition(repo) + + await interaction.followup.send(message, ephemeral=True) log.info( - "/teamsync - scanned=%d, assigned=%d, removed=%d, created=%d, errors=%d", + "/teamsync - scanned=%d, assigned=%d, removed=%d, created=%d, errors=%d%s", summary.scanned, summary.assigned, summary.removed, len(summary.created), len(summary.errors), + f", repositioned={repo.moved}" if repo else "", ) @@ -85,3 +94,17 @@ def _format_summary(summary) -> str: text += "\n\n" + S.TEAMSYNC_UI["no_changes"] return text + + +def _format_reposition(repo) -> str: + lines = [S.TEAMSYNC_UI["reposition_header"]] + if not repo.moved: + lines.append(S.TEAMSYNC_UI["reposition_none"]) + else: + lines.append(S.TEAMSYNC_UI["repositioned"].format(count=repo.moved)) + lines.extend(repo.moves[:20]) + if len(repo.moves) > 20: + lines.append(S.TEAMSYNC_UI["changes_more"].format(count=len(repo.moves) - 20)) + if repo.errors: + lines.append(S.TEAMSYNC_UI["errors"].format(count=len(repo.errors))) + return "\n".join(lines) diff --git a/config.py b/config.py index 37435ad..309b301 100644 --- a/config.py +++ b/config.py @@ -45,6 +45,19 @@ BIRTHDAY_CHANNEL_ID = ( BIRTHDAY_WINDOW_DAYS = int(os.getenv("BIRTHDAY_WINDOW_DAYS", "7")) BASE_ROLE_IDS: list[int] = [1478304631930228779, 1478302278862766190] +# Team-section "divider" roles in the community guild. When a new team role is +# auto-created it is positioned directly under the divider for its game, so the +# role list stays grouped by game. Season-specific - update yearly (or override +# via CS2_DIVIDER_ROLE_ID / LOL_DIVIDER_ROLE_ID). 0 disables placement for that +# game (the role is still created, just left at the bottom of the list). +CS2_DIVIDER_ROLE_ID = _env_int("CS2_DIVIDER_ROLE_ID", 1498736834656604251) +LOL_DIVIDER_ROLE_ID = _env_int("LOL_DIVIDER_ROLE_ID", 1498736949706490017) +# Keyed by the canonical game code produced by core.sheets._detect_game. +TEAM_DIVIDER_ROLE_IDS: dict[str, int] = { + "CS2": CS2_DIVIDER_ROLE_ID, + "LoL": LOL_DIVIDER_ROLE_ID, +} + def _parse_admin_roles(raw: str) -> dict[int, set[int]]: """Parse DISCORD_ADMIN_ROLES env var as "guild_id:role_id[:role_id...],guild_id:role_id...". diff --git a/core/member_sync.py b/core/member_sync.py index 0c89117..7b72c87 100644 --- a/core/member_sync.py +++ b/core/member_sync.py @@ -83,6 +83,15 @@ class TeamSyncSummary: errors: list[str] = field(default_factory=list) +@dataclass +class RepositionSummary: + """Aggregate outcome of the opt-in existing-team-role reposition pass.""" + scanned: int = 0 # existing team roles considered + moved: int = 0 # roles relocated under their divider + moves: list[str] = field(default_factory=list) # "role -> game" lines + errors: list[str] = field(default_factory=list) + + def _format_nickname(full_name: str) -> str: """Format a nickname from a full name: first name + last name initial. @@ -247,6 +256,35 @@ async def sync_member( return result +async def _position_team_role_under_divider( + guild: discord.Guild, + role: discord.Role, + team_name: str, + result: TeamSyncResult, +) -> None: + """Move a freshly-created team role directly under its game's divider role. + + The team's game comes from the registration sheet; the divider role IDs come + from config (``TEAM_DIVIDER_ROLE_IDS``). Best-effort: if the game/divider is + unknown or the move is refused, the role just stays where it was created. + """ + game = sheets.get_game_for_team(team_name) + divider_id = config.TEAM_DIVIDER_ROLE_IDS.get(game) if game else None + if not divider_id: + return + divider = guild.get_role(divider_id) + if divider is None: + log.warning("Divider role %s for game %s not found in guild", divider_id, game) + return + try: + await role.edit(position=divider.position, reason="Team sync: paiguta tiimide alla") + log.info("Positioned team role %r under the %s divider", team_name, game) + except discord.Forbidden: + result.errors.append(f"Tiimirolli '{team_name}' paigutamiseks puudub õigus") + except discord.HTTPException as e: + result.errors.append(f"Tiimirolli '{team_name}' paigutamine ebaõnnestus: {e}") + + async def sync_team_role( member: discord.Member, guild: discord.Guild, @@ -284,6 +322,8 @@ async def sync_team_role( 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}") + if result.created and desired is not None: + await _position_team_role_under_divider(guild, desired, team_name, result) # 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] @@ -342,6 +382,69 @@ async def sync_all_team_roles( return summary +def _resolve_dividers(guild: discord.Guild) -> dict[str, discord.Role]: + """Map each configured game code to its divider role present in the guild.""" + dividers: dict[str, discord.Role] = {} + for game, rid in config.TEAM_DIVIDER_ROLE_IDS.items(): + if not rid: + continue + role = guild.get_role(rid) + if role is not None: + dividers[game] = role + return dividers + + +async def reposition_team_roles( + guild: discord.Guild, + log: logging.Logger = log, +) -> RepositionSummary: + """Move EXISTING team roles under their game's divider (opt-in cleanup). + + Unlike creation-time placement this also relocates team roles that were made + before divider placement existed and are sitting at the bottom of the list. + Sheet-driven: a role's game (and thus its divider) comes from + ``sheets.get_game_for_team``; roles whose team is no longer in the sheet, or + whose game/divider is unknown, are left untouched. + + Idempotent: a role already within its section band (below its own divider and + above the next divider down) is skipped, so re-running moves nothing. + """ + summary = RepositionSummary() + teams = sheets.all_team_names() + dividers = _resolve_dividers(guild) + if not teams or not dividers: + return summary + + for role in list(guild.roles): + if role.name not in teams: + continue + summary.scanned += 1 + game = sheets.get_game_for_team(role.name) + divider = dividers.get(game) if game else None + if divider is None: + continue # unknown game / divider not in guild -> leave in place + + # Section band = (highest divider below this one, this divider). A role + # already inside it is grouped correctly; only relocate outliers. + lower = max( + (d.position for d in dividers.values() if d.position < divider.position), + default=0, + ) + if lower < role.position < divider.position: + continue + + try: + await role.edit(position=divider.position, reason="Team sync: reposition") + summary.moved += 1 + summary.moves.append(f"{role.name} -> {game}") + log.info("Repositioned team role %r under the %s divider", role.name, game) + except discord.Forbidden: + summary.errors.append(f"Tiimirolli '{role.name}' paigutamiseks puudub õigus") + except discord.HTTPException as e: + summary.errors.append(f"Tiimirolli '{role.name}' paigutamine ebaõnnestus: {e}") + return summary + + async def announce_birthday( member: discord.Member, bot: discord.Client, diff --git a/core/sheets.py b/core/sheets.py index 530bc8e..852d6e0 100644 --- a/core/sheets.py +++ b/core/sheets.py @@ -275,10 +275,20 @@ _CITIZENSHIP_RE = re.compile(r"\(\s*[A-Za-z]{2,4}\s*\)") _TEAM_NAME_HEADER = "team name" _LINEUP_HEADER_PREFIX = "lineup" +# Canonical game code -> substrings that identify that game's section title. +# The sheet stacks a CS2 section and a LoL section, each introduced by a title +# row like "TipiLAN 2026 CS2 Registration Log"; teams are tagged with the game +# of the section they sit under so their Discord role can be placed accordingly. +_GAME_KEYWORDS: list[tuple[str, tuple[str, ...]]] = [ + ("CS2", ("cs2", "counter-strike", "counter strike", "csgo", "cs:go")), + ("LoL", ("lol", "league of legends", "league")), +] + # 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_game: dict[str, str] = {} # team name -> game code ("CS2"/"LoL") def parse_lineup(cell: str) -> list[str]: @@ -321,20 +331,34 @@ def _cell(row: list, idx: int) -> str: return str(row[idx]) if 0 <= idx < len(row) else "" -def parse_team_rosters(rows: list[list]) -> dict[str, list[str]]: - """Extract {team_name: [nickname, ...]} from a tab's raw rows. +def _detect_game(row: list) -> str | None: + """Classify a row as a CS2 / LoL section title, or None if it's neither.""" + text = " ".join(str(c) for c in row).lower() + for game, keys in _GAME_KEYWORDS: + if any(k in text for k in keys): + return game + return None + + +def _scan_sections(rows: list[list]): + """Yield ``(team, players, game)`` for every team row across the sheet. 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. + Handles multiple stacked sections with differing layouts in one tab. The + most recent game section title seen (via :func:`_detect_game`) tags every + team in the block that follows. """ - rosters: dict[str, list[str]] = {} + current_game: str | None = None 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: + game = _detect_game(rows[i]) # title / meta row - may name the game + if game: + current_game = game i += 1 continue i += 1 # move past the header into the data block @@ -343,12 +367,28 @@ def parse_team_rosters(rows: list[list]) -> dict[str, list[str]]: 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) + yield team, players, current_game i += 1 + + +def parse_team_rosters(rows: list[list]) -> dict[str, list[str]]: + """Extract {team_name: [nickname, ...]} from a tab's raw rows.""" + rosters: dict[str, list[str]] = {} + for team, players, _game in _scan_sections(rows): + if players: + rosters.setdefault(team, []).extend(players) return rosters +def parse_team_games(rows: list[list]) -> dict[str, str]: + """Extract {team_name: game_code} from a tab's raw rows (first game wins).""" + games: dict[str, str] = {} + for team, _players, game in _scan_sections(rows): + if game and team not in games: + games[team] = game + return games + + def build_username_index(rosters: dict[str, list[str]]) -> dict[str, str]: """Invert {team: [usernames]} into {normalized username: team}. @@ -371,19 +411,24 @@ def build_username_index(rosters: dict[str, list[str]]) -> dict[str, str]: def _refresh_teams_sync() -> dict[str, list[str]]: - global _team_roster, _team_by_username, _team_names + global _team_roster, _team_by_username, _team_names, _team_game 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]] = {} + games: dict[str, str] = {} for ws in spreadsheet.worksheets(): - for team, players in parse_team_rosters(ws.get_all_values()).items(): + vals = ws.get_all_values() + for team, players in parse_team_rosters(vals).items(): rosters.setdefault(team, []).extend(players) + for team, game in parse_team_games(vals).items(): + games.setdefault(team, game) _team_roster = rosters _team_by_username = build_username_index(rosters) _team_names = set(rosters) + _team_game = games return rosters @@ -408,6 +453,11 @@ def all_team_names() -> set[str]: return set(_team_names) +def get_game_for_team(team: str) -> str | None: + """Return the game code ('CS2'/'LoL') a team is registered under, or None.""" + return _team_game.get(team) + + def get_team_rosters() -> dict[str, list[str]]: """Current {team: [usernames]} cache (mainly for diagnostics/tests).""" return _team_roster diff --git a/strings/commands.py b/strings/commands.py index 7c9ce03..9060e1c 100644 --- a/strings/commands.py +++ b/strings/commands.py @@ -82,6 +82,7 @@ CMD: dict[str, str] = { # --------------------------------------------------------------------------- OPT: dict[str, str] = { + "teamsync_reposition": "Paiguta ka olemasolevad tiimirollid nende mängu jaotise alla", "admin_kasutaja": "Kasutaja", "admin_põhjus": "Põhjus (saadetakse kasutajale DM kaudu)", "admincoins_kogus": "Positiivne = anna, negatiivne = võta", diff --git a/strings/member.py b/strings/member.py index a604887..70da2c5 100644 --- a/strings/member.py +++ b/strings/member.py @@ -113,4 +113,7 @@ TEAMSYNC_UI: dict[str, str] = { "no_changes": "✨ Kõik tiimirollid olid juba korras.", "changes_header": "**Muudatused:**", "changes_more": "... ja {count} rohkem", + "reposition_header": "**Ümberpaigutus:**", + "repositioned": "📦 Ümber paigutatud rolle: {count}", + "reposition_none": "✨ Olemasolevad tiimirollid olid juba õiges sektsioonis.", } diff --git a/tests/test_team_sync.py b/tests/test_team_sync.py index 85f781e..ba525b0 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 @@ -87,6 +88,15 @@ def test_parse_team_rosters_ignores_non_table_content(): assert sheets.parse_team_rosters([["just", "some", "prose"], ["more"]]) == {} +def test_parse_team_games_tags_each_team_with_its_section(): + # Teams inherit the game of the stacked section they sit under. + assert sheets.parse_team_games(SHEET_ROWS) == { + "Piirivalvurid": "CS2", + "GENESIS": "CS2", + "Pushing 30s": "LoL", + } + + def test_build_username_index_is_case_insensitive(): index = sheets.build_username_index({"GENESIS": ["Kapa", "neaQ"]}) assert index == {"kapa": "GENESIS", "neaq": "GENESIS"} @@ -95,9 +105,14 @@ def test_build_username_index_is_case_insensitive(): # --- sync_team_role behaviour (roster-independent) ------------------------- class FakeRole: - def __init__(self, rid: int, name: str): + def __init__(self, rid: int, name: str, position: int = 0): self.id = rid self.name = name + self.position = position + + async def edit(self, position=None, reason=None): + if position is not None: + self.position = position def __eq__(self, other): return isinstance(other, FakeRole) and other.id == self.id @@ -128,6 +143,9 @@ class FakeGuild: self._next = 9000 self.created: list[str] = [] + def get_role(self, rid): + return next((r for r in self.roles if r.id == rid), None) + async def create_role(self, name, reason=None): self._next += 1 role = FakeRole(self._next, name) @@ -222,3 +240,79 @@ def test_sync_all_team_roles_aggregates_and_skips_bots(monkeypatch): assert summary.assigned == 1 assert summary.removed == 0 assert summary.changes == ["kapa: +GENESIS"] + + +def test_new_team_role_is_positioned_under_its_game_divider(monkeypatch): + divider = FakeRole(500, "===== COUNTER-STRIKE 2 2026 =====", position=10) + member = FakeMember(1, "tft", roles=[]) + guild = FakeGuild([divider], members=[member]) + + monkeypatch.setattr(sheets, "get_team_for_username", lambda n: "NEWTEAM") + monkeypatch.setattr(sheets, "all_team_names", lambda: {"NEWTEAM"}) + monkeypatch.setattr(sheets, "get_game_for_team", lambda t: "CS2") + monkeypatch.setattr(config, "TEAM_DIVIDER_ROLE_IDS", {"CS2": 500}) + + result = run(member_sync.sync_team_role(member, guild)) + + assert result.created == "NEWTEAM" + new_role = next(r for r in guild.roles if r.name == "NEWTEAM") + assert new_role.position == 10 # slotted at the divider + + +def test_new_team_role_without_known_divider_is_left_in_place(monkeypatch): + member = FakeMember(1, "tft", roles=[]) + guild = FakeGuild([], members=[member]) + + monkeypatch.setattr(sheets, "get_team_for_username", lambda n: "NEWTEAM") + monkeypatch.setattr(sheets, "all_team_names", lambda: {"NEWTEAM"}) + monkeypatch.setattr(sheets, "get_game_for_team", lambda t: None) # game unknown + monkeypatch.setattr(config, "TEAM_DIVIDER_ROLE_IDS", {"CS2": 500}) + + result = run(member_sync.sync_team_role(member, guild)) + + assert result.created == "NEWTEAM" # still created, just not moved + new_role = next(r for r in guild.roles if r.name == "NEWTEAM") + assert new_role.position == 0 # default, untouched + + +# --- reposition_team_roles (opt-in cleanup of existing roles) --------------- + +def test_reposition_moves_out_of_section_roles_and_skips_placed_ones(monkeypatch): + cs2_div = FakeRole(100, "CS2 divider", position=20) + lol_div = FakeRole(200, "LoL divider", position=10) + genesis = FakeRole(1, "GENESIS", position=2) # CS2 team stuck at bottom + pushing = FakeRole(2, "Pushing 30s", position=5) # LoL team already in band + unrelated = FakeRole(3, "Moderator", position=30) # not a team - ignored + guild = FakeGuild([cs2_div, lol_div, genesis, pushing, unrelated]) + + games = {"GENESIS": "CS2", "Pushing 30s": "LoL"} + monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS", "Pushing 30s"}) + monkeypatch.setattr(sheets, "get_game_for_team", lambda t: games.get(t)) + monkeypatch.setattr(config, "TEAM_DIVIDER_ROLE_IDS", {"CS2": 100, "LoL": 200}) + + repo = run(member_sync.reposition_team_roles(guild)) + + assert repo.scanned == 2 # only the two team roles + assert repo.moved == 1 # only GENESIS was out of place + assert repo.moves == ["GENESIS -> CS2"] + assert genesis.position == 20 # moved under the CS2 divider + assert pushing.position == 5 # already in its band, untouched + assert unrelated.position == 30 # non-team role never considered + + +def test_reposition_leaves_role_whose_team_is_not_in_sheet(monkeypatch): + cs2_div = FakeRole(100, "CS2 divider", position=20) + lol_div = FakeRole(200, "LoL divider", position=10) + ghost = FakeRole(1, "GhostTeam", position=2) # a team role, but gone from sheet + guild = FakeGuild([cs2_div, lol_div, ghost]) + + # Team no longer registered -> not in all_team_names, so never scanned/moved. + monkeypatch.setattr(sheets, "all_team_names", lambda: {"GENESIS"}) + monkeypatch.setattr(sheets, "get_game_for_team", lambda t: None) + monkeypatch.setattr(config, "TEAM_DIVIDER_ROLE_IDS", {"CS2": 100, "LoL": 200}) + + repo = run(member_sync.reposition_team_roles(guild)) + + assert repo.scanned == 0 + assert repo.moved == 0 + assert ghost.position == 2 # left exactly where it was