forked from sass/tipibot
Compare commits
3 Commits
feat/team-
...
b20bca9244
| Author | SHA1 | Date | |
|---|---|---|---|
| b20bca9244 | |||
|
|
d48a436e26 | ||
|
|
deab0f7a55 |
10
.env.example
10
.env.example
@@ -14,6 +14,16 @@ SHEET_ID=your-google-sheet-id-here
|
||||
# the economy/community bot. Leave unset to disable team-role sync entirely.
|
||||
TEAM_SHEET_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
|
||||
|
||||
|
||||
48
README.md
48
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_<GAME>_<YEAR>` | 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`.
|
||||
|
||||
9
bot.py
9
bot.py
@@ -359,11 +359,14 @@ async def team_sync_hourly():
|
||||
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:
|
||||
if summary.assigned or summary.removed or summary.created or summary.positioned or summary.errors:
|
||||
log.info(
|
||||
"team_sync_hourly: assigned=%d, removed=%d, created=%d, errors=%d",
|
||||
summary.assigned, summary.removed, len(summary.created), len(summary.errors),
|
||||
"team_sync_hourly: assigned=%d, removed=%d, created=%d, positioned=%d, errors=%d",
|
||||
summary.assigned, summary.removed, len(summary.created),
|
||||
summary.positioned, len(summary.errors),
|
||||
)
|
||||
for err in summary.errors:
|
||||
log.warning("team_sync_hourly: %s", err)
|
||||
|
||||
|
||||
@team_sync_hourly.before_loop
|
||||
|
||||
@@ -50,11 +50,12 @@ def register_economy_team_commands(
|
||||
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, errors=%d",
|
||||
"/teamsync - scanned=%d, assigned=%d, removed=%d, created=%d, positioned=%d, errors=%d",
|
||||
summary.scanned,
|
||||
summary.assigned,
|
||||
summary.removed,
|
||||
len(summary.created),
|
||||
summary.positioned,
|
||||
len(summary.errors),
|
||||
)
|
||||
|
||||
@@ -71,6 +72,8 @@ def _format_summary(summary) -> str:
|
||||
# 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.errors:
|
||||
lines.append(S.TEAMSYNC_UI["errors"].format(count=len(summary.errors)))
|
||||
|
||||
|
||||
37
config.py
37
config.py
@@ -69,6 +69,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_<SUFFIX> 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", "")
|
||||
|
||||
@@ -79,6 +79,7 @@ class TeamSyncSummary:
|
||||
assigned: int = 0
|
||||
removed: int = 0
|
||||
created: list[str] = field(default_factory=list)
|
||||
positioned: int = 0 # team roles moved under a divider
|
||||
changes: list[str] = field(default_factory=list) # human-readable per-member lines
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
@@ -309,6 +310,122 @@ async def sync_team_role(
|
||||
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_dividers = sheets.get_team_dividers() # {team: divider role ID}
|
||||
if not team_dividers:
|
||||
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_dividers.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}")
|
||||
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,
|
||||
@@ -339,6 +456,11 @@ async def sync_all_team_roles(
|
||||
if res.removed:
|
||||
bits.append("-" + ", -".join(res.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
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ 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
|
||||
@@ -279,6 +280,19 @@ _LINEUP_HEADER_PREFIX = "lineup"
|
||||
_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]:
|
||||
@@ -321,22 +335,37 @@ 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 _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.
|
||||
Handles multiple stacked sections with differing layouts in one tab, and
|
||||
tags each with the most recent merged title row seen above it.
|
||||
"""
|
||||
rosters: dict[str, list[str]] = {}
|
||||
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:
|
||||
title = 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()
|
||||
@@ -346,9 +375,44 @@ def parse_team_rosters(rows: list[list]) -> dict[str, list[str]]:
|
||||
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_<SUFFIX>`` 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}.
|
||||
|
||||
@@ -371,19 +435,25 @@ 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_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 team, players in parse_team_rosters(ws.get_all_values()).items():
|
||||
for section in parse_team_sections(ws.get_all_values()):
|
||||
divider = resolve_divider(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
|
||||
|
||||
|
||||
@@ -411,3 +481,12 @@ def all_team_names() -> set[str]:
|
||||
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)
|
||||
|
||||
@@ -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`, `JAIL_DURATION`, `COIN`, `get_user`/`_commit`/`_txn`), `income.py`, `gambling.py`, `fishing.py`, `jail.py`, `heist.py`, `prestige.py`, `shop.py`, `levels.py`, `quests.py`, `leaderboards.py`, `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_<GAME>_<YEAR>="<exact Discord role name>"`. 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:
|
||||
|
||||
@@ -109,6 +109,7 @@ TEAMSYNC_UI: dict[str, str] = {
|
||||
"assigned": "✅ Tiimirolle antud: {count}",
|
||||
"removed": "➖ Tiimirolle eemaldatud: {count}",
|
||||
"created": "🆕 Loodud uusi tiimirolle: {roles}",
|
||||
"positioned": "📍 Eraldaja alla paigutatud: {count}",
|
||||
"errors": "⚠️ Vead: {count}",
|
||||
"no_changes": "✨ Kõik tiimirollid olid juba korras.",
|
||||
"changes_header": "**Muudatused:**",
|
||||
|
||||
@@ -87,6 +87,108 @@ def test_parse_team_rosters_ignores_non_table_content():
|
||||
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
|
||||
|
||||
|
||||
# --- 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"}
|
||||
|
||||
Reference in New Issue
Block a user