forked from sass/tipibot
feat(teams): place new team roles under their game's divider role
Auto-created team roles now land directly under the CS2 / LoL "divider" role in the community guild's role list, keeping it grouped by game. - config: CS2_DIVIDER_ROLE_ID / LOL_DIVIDER_ROLE_ID (season-specific, env overridable) exposed as TEAM_DIVIDER_ROLE_IDS keyed by game code. - core/sheets: tag each team with its game (CS2/LoL) from the section title it sits under; shared _scan_sections generator feeds both parse_team_rosters and the new parse_team_games; get_game_for_team accessor + _team_game cache. - core/member_sync: on creating a team role, position it at its game divider's slot (best-effort; left in place if game/divider unknown or move refused). - .env.example + tests for game tagging and role placement. Each participant is in exactly one game, so one role per team is unambiguous. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R6JZkyszyDFuFtk25WBbcR
This commit is contained in:
@@ -247,6 +247,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 +313,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]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user