forked from sass/tipibot
Compare commits
8 Commits
feat/team-
...
fb08bc56d7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb08bc56d7 | ||
|
|
a280ba05cc | ||
|
|
3c8927184b | ||
| b813ed5f81 | |||
|
|
c19e67b5ab | ||
| b20bca9244 | |||
|
|
d48a436e26 | ||
|
|
deab0f7a55 |
26
.env.example
26
.env.example
@@ -10,15 +10,27 @@ DISCORD_TOKEN=
|
|||||||
SHEET_ID=your-google-sheet-id-here
|
SHEET_ID=your-google-sheet-id-here
|
||||||
|
|
||||||
# Separate spreadsheet holding tournament team registrations (Team Name + lineup
|
# Separate spreadsheet holding tournament team registrations (Team Name + lineup
|
||||||
# of Discord usernames). Optional; drives /teamsync + hourly team-role sync on
|
# of Discord usernames). Optional; a FALLBACK source for /teamsync + hourly
|
||||||
# the economy/community bot. Leave unset to disable team-role sync entirely.
|
# team-role sync on the economy/community bot. Leave unset to disable it.
|
||||||
TEAM_SHEET_ID=
|
TEAM_SHEET_ID=
|
||||||
|
|
||||||
# Team-section "divider" role IDs. A newly-created team role is positioned
|
# Fienta ticketing - PRIMARY source for team-role sync. The registration collects
|
||||||
# directly under the divider for its game (CS2 / LoL). Season-specific - update
|
# each competitor's Discord username + team name per ticket, so matching is by
|
||||||
# yearly. 0 = leave that game's new roles at the bottom of the role list.
|
# real Discord handle (not game nickname). Get an API token from the Fienta admin
|
||||||
CS2_DIVIDER_ROLE_ID=
|
# (organizer settings) and the event's numeric ID from its dashboard URL. Leave
|
||||||
LOL_DIVIDER_ROLE_ID=
|
# 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
|
# Path to Google service account credentials JSON
|
||||||
GOOGLE_CREDS_PATH=credentials.json
|
GOOGLE_CREDS_PATH=credentials.json
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -9,4 +9,7 @@ pocketbase.exe
|
|||||||
pocketbase
|
pocketbase
|
||||||
pb_data/
|
pb_data/
|
||||||
pb_migrations/
|
pb_migrations/
|
||||||
logs/
|
logs/
|
||||||
|
fientalog
|
||||||
|
fientatickets
|
||||||
|
fientaorders
|
||||||
|
|||||||
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)
|
2. [Member Management](#member-management)
|
||||||
3. [Admin Commands](#admin-commands)
|
3. [Admin Commands](#admin-commands)
|
||||||
4. [Birthday System](#birthday-system)
|
4. [Birthday System](#birthday-system)
|
||||||
5. [TipiCOIN Economy](#tipicoin-economy)
|
5. [Tournament Team Roles](#tournament-team-roles)
|
||||||
6. [Project Structure](#project-structure)
|
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_DEV` | PocketBase collection used by `BOT_PROFILE=dev` |
|
||||||
| `PB_ECONOMY_COLLECTION_ECONOMY` | PocketBase collection used by `BOT_PROFILE=economy` |
|
| `PB_ECONOMY_COLLECTION_ECONOMY` | PocketBase collection used by `BOT_PROFILE=economy` |
|
||||||
| `PB_ECONOMY_COLLECTION` | Legacy fallback collection (optional) |
|
| `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
|
### 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 |
|
| `/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 |
|
| `/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 |
|
| `/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 |
|
| `/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 |
|
| `/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
|
## 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`.
|
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`.
|
||||||
|
|||||||
28
bot.py
28
bot.py
@@ -21,7 +21,7 @@ import psutil
|
|||||||
|
|
||||||
import config
|
import config
|
||||||
import strings as S
|
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.admin import is_bot_admin
|
||||||
from core.member_sync import SyncResult, sync_all_team_roles
|
from core.member_sync import SyncResult, sync_all_team_roles
|
||||||
from commands.dev_member_commands import register_dev_member_commands
|
from commands.dev_member_commands import register_dev_member_commands
|
||||||
@@ -339,31 +339,38 @@ async def before_birthday_daily():
|
|||||||
|
|
||||||
@tasks.loop(hours=1)
|
@tasks.loop(hours=1)
|
||||||
async def team_sync_hourly():
|
async def team_sync_hourly():
|
||||||
"""Reload the tournament registration sheet and re-apply team roles.
|
"""Reload the tournament registration (Fienta + sheet) and re-apply team roles.
|
||||||
|
|
||||||
Economy profile only (the tournament players live in the community guild).
|
Economy profile only (the tournament players live in the community guild).
|
||||||
Runs the first iteration immediately on start, so this also covers the
|
Runs the first iteration immediately on start, so this also covers the
|
||||||
initial load at boot. No-op when TEAM_SHEET_ID is unset.
|
initial load at boot. No-op when neither Fienta nor the team sheet is set.
|
||||||
"""
|
"""
|
||||||
if IS_DEV_PROFILE or not config.TEAM_SHEET_ID:
|
if IS_DEV_PROFILE or not (config.TEAM_SHEET_ID or config.FIENTA_API_TOKEN):
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
rosters = await sheets.refresh_teams()
|
rosters = await sheets.refresh_teams()
|
||||||
|
fienta_teams = await fienta.refresh_teams()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error("team_sync_hourly: failed to load team sheet: %s", e)
|
log.error("team_sync_hourly: failed to load team data: %s", e)
|
||||||
return
|
return
|
||||||
if not rosters:
|
if not rosters and not fienta_teams:
|
||||||
return
|
return
|
||||||
guild = bot.get_guild(config.GUILD_ID)
|
guild = bot.get_guild(config.GUILD_ID)
|
||||||
if guild is None:
|
if guild is None:
|
||||||
log.warning("team_sync_hourly: guild %s not found", config.GUILD_ID)
|
log.warning("team_sync_hourly: guild %s not found", config.GUILD_ID)
|
||||||
return
|
return
|
||||||
summary = await sync_all_team_roles(guild, log)
|
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.divider_assigned or summary.divider_removed or summary.errors):
|
||||||
log.info(
|
log.info(
|
||||||
"team_sync_hourly: assigned=%d, removed=%d, created=%d, errors=%d",
|
"team_sync_hourly: assigned=%d, removed=%d, created=%d, positioned=%d, "
|
||||||
summary.assigned, summary.removed, len(summary.created), len(summary.errors),
|
"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
|
@team_sync_hourly.before_loop
|
||||||
@@ -469,7 +476,8 @@ async def on_ready():
|
|||||||
log.info("Birthday daily task started (fires 09:00 Tallinn time)")
|
log.info("Birthday daily task started (fires 09:00 Tallinn time)")
|
||||||
|
|
||||||
# Start hourly tournament team-role sync (economy/community guild)
|
# Start hourly tournament team-role sync (economy/community guild)
|
||||||
if not IS_DEV_PROFILE and config.TEAM_SHEET_ID and not team_sync_hourly.is_running():
|
if (not IS_DEV_PROFILE and (config.TEAM_SHEET_ID or config.FIENTA_API_TOKEN)
|
||||||
|
and not team_sync_hourly.is_running()):
|
||||||
team_sync_hourly.start()
|
team_sync_hourly.start()
|
||||||
log.info("Team-role sync task started (hourly, from the registration sheet)")
|
log.info("Team-role sync task started (hourly, from the registration sheet)")
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ import logging
|
|||||||
import discord
|
import discord
|
||||||
from discord import app_commands
|
from discord import app_commands
|
||||||
|
|
||||||
from core import sheets
|
from core import fienta, sheets
|
||||||
from core.admin import bot_admin_check
|
from core.admin import bot_admin_check
|
||||||
from core.member_sync import reposition_team_roles, sync_all_team_roles
|
from core.member_sync import sync_all_team_roles
|
||||||
import strings as S
|
import strings as S
|
||||||
|
|
||||||
|
|
||||||
@@ -27,9 +27,8 @@ def register_economy_team_commands(
|
|||||||
) -> None:
|
) -> None:
|
||||||
@tree.command(name="teamsync", description=S.CMD["teamsync"])
|
@tree.command(name="teamsync", description=S.CMD["teamsync"])
|
||||||
@app_commands.guild_only()
|
@app_commands.guild_only()
|
||||||
@app_commands.describe(reposition=S.OPT["teamsync_reposition"])
|
|
||||||
@bot_admin_check()
|
@bot_admin_check()
|
||||||
async def cmd_teamsync(interaction: discord.Interaction, reposition: bool = False):
|
async def cmd_teamsync(interaction: discord.Interaction):
|
||||||
await interaction.response.defer(ephemeral=True)
|
await interaction.response.defer(ephemeral=True)
|
||||||
|
|
||||||
guild = interaction.guild
|
guild = interaction.guild
|
||||||
@@ -39,32 +38,29 @@ def register_economy_team_commands(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
rosters = await sheets.refresh_teams()
|
rosters = await sheets.refresh_teams()
|
||||||
|
fienta_teams = await fienta.refresh_teams()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await interaction.followup.send(
|
await interaction.followup.send(
|
||||||
S.TEAMSYNC_UI["refresh_error"].format(error=e), ephemeral=True
|
S.TEAMSYNC_UI["refresh_error"].format(error=e), ephemeral=True
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if not rosters:
|
if not rosters and not fienta_teams:
|
||||||
await interaction.followup.send(S.TEAMSYNC_UI["disabled"], ephemeral=True)
|
await interaction.followup.send(S.TEAMSYNC_UI["disabled"], ephemeral=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
summary = await sync_all_team_roles(guild, log)
|
summary = await sync_all_team_roles(guild, log)
|
||||||
message = _format_summary(summary)
|
await interaction.followup.send(_format_summary(summary), ephemeral=True)
|
||||||
|
|
||||||
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(
|
log.info(
|
||||||
"/teamsync - scanned=%d, assigned=%d, removed=%d, created=%d, errors=%d%s",
|
"/teamsync - scanned=%d, assigned=%d, removed=%d, created=%d, "
|
||||||
|
"positioned=%d, divider_assigned=%d, divider_removed=%d, errors=%d",
|
||||||
summary.scanned,
|
summary.scanned,
|
||||||
summary.assigned,
|
summary.assigned,
|
||||||
summary.removed,
|
summary.removed,
|
||||||
len(summary.created),
|
len(summary.created),
|
||||||
|
summary.positioned,
|
||||||
|
summary.divider_assigned,
|
||||||
|
summary.divider_removed,
|
||||||
len(summary.errors),
|
len(summary.errors),
|
||||||
f", repositioned={repo.moved}" if repo else "",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -80,6 +76,12 @@ def _format_summary(summary) -> str:
|
|||||||
# several members in one run - de-dupe for the report.
|
# several members in one run - de-dupe for the report.
|
||||||
unique = list(dict.fromkeys(summary.created))
|
unique = list(dict.fromkeys(summary.created))
|
||||||
lines.append(S.TEAMSYNC_UI["created"].format(roles=", ".join(unique)))
|
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:
|
if summary.errors:
|
||||||
lines.append(S.TEAMSYNC_UI["errors"].format(count=len(summary.errors)))
|
lines.append(S.TEAMSYNC_UI["errors"].format(count=len(summary.errors)))
|
||||||
|
|
||||||
@@ -94,17 +96,3 @@ def _format_summary(summary) -> str:
|
|||||||
text += "\n\n" + S.TEAMSYNC_UI["no_changes"]
|
text += "\n\n" + S.TEAMSYNC_UI["no_changes"]
|
||||||
|
|
||||||
return text
|
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)
|
|
||||||
|
|||||||
57
config.py
57
config.py
@@ -28,6 +28,13 @@ SHEET_ID = os.getenv("SHEET_ID")
|
|||||||
TEAM_SHEET_ID = os.getenv("TEAM_SHEET_ID")
|
TEAM_SHEET_ID = os.getenv("TEAM_SHEET_ID")
|
||||||
GOOGLE_CREDS_PATH = os.getenv("GOOGLE_CREDS_PATH", "credentials.json")
|
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)
|
_LEGACY_GUILD_ID = _env_int("GUILD_ID", 0)
|
||||||
GUILD_ID_DEV = _env_int("GUILD_ID_DEV", _LEGACY_GUILD_ID)
|
GUILD_ID_DEV = _env_int("GUILD_ID_DEV", _LEGACY_GUILD_ID)
|
||||||
GUILD_ID_ECONOMY = _env_int("GUILD_ID_ECONOMY", _LEGACY_GUILD_ID)
|
GUILD_ID_ECONOMY = _env_int("GUILD_ID_ECONOMY", _LEGACY_GUILD_ID)
|
||||||
@@ -45,19 +52,6 @@ BIRTHDAY_CHANNEL_ID = (
|
|||||||
BIRTHDAY_WINDOW_DAYS = int(os.getenv("BIRTHDAY_WINDOW_DAYS", "7"))
|
BIRTHDAY_WINDOW_DAYS = int(os.getenv("BIRTHDAY_WINDOW_DAYS", "7"))
|
||||||
BASE_ROLE_IDS: list[int] = [1478304631930228779, 1478302278862766190]
|
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]]:
|
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...".
|
"""Parse DISCORD_ADMIN_ROLES env var as "guild_id:role_id[:role_id...],guild_id:role_id...".
|
||||||
@@ -82,6 +76,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", ""))
|
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_URL = os.getenv("PB_URL", "http://127.0.0.1:8090")
|
||||||
PB_ADMIN_EMAIL = os.getenv("PB_ADMIN_EMAIL", "")
|
PB_ADMIN_EMAIL = os.getenv("PB_ADMIN_EMAIL", "")
|
||||||
PB_ADMIN_PASSWORD = os.getenv("PB_ADMIN_PASSWORD", "")
|
PB_ADMIN_PASSWORD = os.getenv("PB_ADMIN_PASSWORD", "")
|
||||||
|
|||||||
190
core/fienta.py
Normal file
190
core/fienta.py
Normal file
@@ -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_<SUFFIX>`` 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
|
||||||
|
}
|
||||||
@@ -11,9 +11,32 @@ from zoneinfo import ZoneInfo
|
|||||||
import discord
|
import discord
|
||||||
|
|
||||||
import config
|
import config
|
||||||
from . import sheets
|
from . import fienta, sheets
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
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"}
|
_PLACEHOLDER = {"-", "x", "n/a", "none", "ei"}
|
||||||
_TZ = ZoneInfo("Europe/Tallinn")
|
_TZ = ZoneInfo("Europe/Tallinn")
|
||||||
|
|
||||||
@@ -65,11 +88,13 @@ class TeamSyncResult:
|
|||||||
added: str | None = None # team role name granted, if any
|
added: str | None = None # team role name granted, if any
|
||||||
removed: list[str] = field(default_factory=list) # stale team roles taken away
|
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
|
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)
|
errors: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def changed(self) -> bool:
|
def changed(self) -> bool:
|
||||||
return bool(self.added or self.removed)
|
return bool(self.added or self.removed or self.divider_added or self.divider_removed)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -79,19 +104,13 @@ class TeamSyncSummary:
|
|||||||
assigned: int = 0
|
assigned: int = 0
|
||||||
removed: int = 0
|
removed: int = 0
|
||||||
created: list[str] = field(default_factory=list)
|
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
|
changes: list[str] = field(default_factory=list) # human-readable per-member lines
|
||||||
errors: list[str] = field(default_factory=list)
|
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:
|
def _format_nickname(full_name: str) -> str:
|
||||||
"""Format a nickname from a full name: first name + last name initial.
|
"""Format a nickname from a full name: first name + last name initial.
|
||||||
|
|
||||||
@@ -256,35 +275,6 @@ async def sync_member(
|
|||||||
return result
|
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(
|
async def sync_team_role(
|
||||||
member: discord.Member,
|
member: discord.Member,
|
||||||
guild: discord.Guild,
|
guild: discord.Guild,
|
||||||
@@ -297,18 +287,21 @@ async def sync_team_role(
|
|||||||
|
|
||||||
* grants the role for the team they're registered on (auto-creating that
|
* grants the role for the team they're registered on (auto-creating that
|
||||||
role in the guild when it does not exist yet);
|
role in the guild when it does not exist yet);
|
||||||
* removes any *other* team role they still carry (left / switched teams).
|
* removes any *other* team role they still carry (left / switched teams);
|
||||||
|
* grants their game's divider role as a participant tag (and strips any
|
||||||
|
other configured divider role they still carry, i.e. switched game).
|
||||||
|
|
||||||
Only role NAMES present in the team sheet are ever added or removed, so no
|
Only team role NAMES present in the team sheet and the configured divider
|
||||||
|
role IDs (``config.TEAM_DIVIDERS``) are ever added or removed, so no
|
||||||
unrelated role is ever at risk. When ``TEAM_SHEET_ID`` is unset the caches
|
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.
|
are empty and this is a no-op returning an unchanged result.
|
||||||
"""
|
"""
|
||||||
result = TeamSyncResult()
|
result = TeamSyncResult()
|
||||||
|
|
||||||
team_name = sheets.get_team_for_username(member.name)
|
team_name = resolve_team(member)
|
||||||
all_teams = sheets.all_team_names()
|
all_teams = all_managed_team_names()
|
||||||
if not all_teams:
|
if not all_teams:
|
||||||
return result # feature switched off (no team sheet loaded)
|
return result # feature switched off (no Fienta token and no team sheet)
|
||||||
|
|
||||||
desired: discord.Role | None = None
|
desired: discord.Role | None = None
|
||||||
if team_name:
|
if team_name:
|
||||||
@@ -322,8 +315,6 @@ async def sync_team_role(
|
|||||||
result.errors.append(f"Tiimirolli '{team_name}' loomiseks puudub õigus")
|
result.errors.append(f"Tiimirolli '{team_name}' loomiseks puudub õigus")
|
||||||
except discord.HTTPException as e:
|
except discord.HTTPException as e:
|
||||||
result.errors.append(f"Tiimirolli '{team_name}' loomine ebaõnnestus: {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).
|
# 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]
|
to_remove = [r for r in member.roles if r.name in all_teams and r.name != team_name]
|
||||||
@@ -346,9 +337,164 @@ async def sync_team_role(
|
|||||||
except discord.HTTPException as e:
|
except discord.HTTPException as e:
|
||||||
result.errors.append(f"Tiimirolli eemaldamise viga kasutajale {member}: {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
|
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(
|
async def sync_all_team_roles(
|
||||||
guild: discord.Guild,
|
guild: discord.Guild,
|
||||||
log: logging.Logger = log,
|
log: logging.Logger = log,
|
||||||
@@ -372,76 +518,26 @@ async def sync_all_team_roles(
|
|||||||
summary.assigned += 1
|
summary.assigned += 1
|
||||||
if res.removed:
|
if res.removed:
|
||||||
summary.removed += len(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:
|
if res.changed:
|
||||||
bits: list[str] = []
|
bits: list[str] = []
|
||||||
if res.added:
|
if res.added:
|
||||||
bits.append(f"+{res.added}")
|
bits.append(f"+{res.added}")
|
||||||
if res.removed:
|
if res.removed:
|
||||||
bits.append("-" + ", -".join(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)}")
|
summary.changes.append(f"{member.display_name}: {', '.join(bits)}")
|
||||||
return summary
|
|
||||||
|
|
||||||
|
# Placement runs after the grant/remove pass so roles created this run are
|
||||||
def _resolve_dividers(guild: discord.Guild) -> dict[str, discord.Role]:
|
# positioned in the same sweep rather than waiting for the next one.
|
||||||
"""Map each configured game code to its divider role present in the guild."""
|
summary.positioned, position_errors = await apply_team_role_positions(guild, log)
|
||||||
dividers: dict[str, discord.Role] = {}
|
summary.errors.extend(position_errors)
|
||||||
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
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
136
core/sheets.py
136
core/sheets.py
@@ -9,6 +9,7 @@ Pure-cache helpers (get_cache, find_*) remain sync.
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
import gspread
|
import gspread
|
||||||
from google.oauth2.service_account import Credentials
|
from google.oauth2.service_account import Credentials
|
||||||
@@ -275,20 +276,23 @@ _CITIZENSHIP_RE = re.compile(r"\(\s*[A-Za-z]{2,4}\s*\)")
|
|||||||
_TEAM_NAME_HEADER = "team name"
|
_TEAM_NAME_HEADER = "team name"
|
||||||
_LINEUP_HEADER_PREFIX = "lineup"
|
_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-sheet caches (mirrors the member-roster cache above)
|
||||||
_team_roster: dict[str, list[str]] = {} # team name -> [Discord username, ...]
|
_team_roster: dict[str, list[str]] = {} # team name -> [Discord username, ...]
|
||||||
_team_by_username: dict[str, str] = {} # normalized username -> team name
|
_team_by_username: dict[str, str] = {} # normalized username -> team name
|
||||||
_team_names: set[str] = set() # universe of all team names
|
_team_names: set[str] = set() # universe of all team names
|
||||||
_team_game: dict[str, str] = {} # team name -> game code ("CS2"/"LoL")
|
_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]:
|
def parse_lineup(cell: str) -> list[str]:
|
||||||
@@ -331,62 +335,86 @@ def _cell(row: list, idx: int) -> str:
|
|||||||
return str(row[idx]) if 0 <= idx < len(row) else ""
|
return str(row[idx]) if 0 <= idx < len(row) else ""
|
||||||
|
|
||||||
|
|
||||||
def _detect_game(row: list) -> str | None:
|
def _merged_title(row: list) -> str | None:
|
||||||
"""Classify a row as a CS2 / LoL section title, or None if it's neither."""
|
"""Return a row's lone non-empty cell - a merged section title - else None.
|
||||||
text = " ".join(str(c) for c in row).lower()
|
|
||||||
for game, keys in _GAME_KEYWORDS:
|
A fully blank row returns None rather than "", so blank separators between
|
||||||
if any(k in text for k in keys):
|
sections do not wipe the title we are holding for the next header row.
|
||||||
return game
|
"""
|
||||||
return None
|
values = [str(c).strip() for c in row if str(c).strip()]
|
||||||
|
return values[0] if len(values) == 1 else None
|
||||||
|
|
||||||
|
|
||||||
def _scan_sections(rows: list[list]):
|
def parse_team_sections(rows: list[list]) -> list[TeamSection]:
|
||||||
"""Yield ``(team, players, game)`` for every team row across the sheet.
|
"""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...'
|
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
|
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.
|
positions) until the team-name column goes blank or a new header appears.
|
||||||
Handles multiple stacked sections with differing layouts in one tab. The
|
Handles multiple stacked sections with differing layouts in one tab, and
|
||||||
most recent game section title seen (via :func:`_detect_game`) tags every
|
tags each with the most recent merged title row seen above it.
|
||||||
team in the block that follows.
|
|
||||||
"""
|
"""
|
||||||
current_game: str | None = None
|
sections: list[TeamSection] = []
|
||||||
|
title = ""
|
||||||
i, n = 0, len(rows)
|
i, n = 0, len(rows)
|
||||||
while i < n:
|
while i < n:
|
||||||
name_col = _find_col(rows[i], lambda c: c.strip().lower() == _TEAM_NAME_HEADER)
|
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))
|
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 name_col is None or lineup_col is None:
|
||||||
game = _detect_game(rows[i]) # title / meta row - may name the game
|
if (text := _merged_title(rows[i])) is not None:
|
||||||
if game:
|
# Accumulate every single-cell row above the header, not just the
|
||||||
current_game = game
|
# 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
|
i += 1
|
||||||
continue
|
continue
|
||||||
|
rosters: dict[str, list[str]] = {}
|
||||||
i += 1 # move past the header into the data block
|
i += 1 # move past the header into the data block
|
||||||
while i < n:
|
while i < n:
|
||||||
team = _cell(rows[i], name_col).strip()
|
team = _cell(rows[i], name_col).strip()
|
||||||
if not team or team.lower() == _TEAM_NAME_HEADER:
|
if not team or team.lower() == _TEAM_NAME_HEADER:
|
||||||
break # blank team-name (or a new header) ends this section
|
break # blank team-name (or a new header) ends this section
|
||||||
players = parse_lineup(_cell(rows[i], lineup_col))
|
players = parse_lineup(_cell(rows[i], lineup_col))
|
||||||
yield team, players, current_game
|
if players:
|
||||||
|
rosters.setdefault(team, []).extend(players)
|
||||||
i += 1
|
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]]:
|
def parse_team_rosters(rows: list[list]) -> dict[str, list[str]]:
|
||||||
"""Extract {team_name: [nickname, ...]} from a tab's raw rows."""
|
"""Flatten every section in a tab into {team_name: [nickname, ...]}."""
|
||||||
rosters: dict[str, list[str]] = {}
|
rosters: dict[str, list[str]] = {}
|
||||||
for team, players, _game in _scan_sections(rows):
|
for section in parse_team_sections(rows):
|
||||||
if players:
|
for team, players in section.rosters.items():
|
||||||
rosters.setdefault(team, []).extend(players)
|
rosters.setdefault(team, []).extend(players)
|
||||||
return rosters
|
return rosters
|
||||||
|
|
||||||
|
|
||||||
def parse_team_games(rows: list[list]) -> dict[str, str]:
|
def resolve_divider(title: str, dividers: dict[str, int] | None = None) -> int | None:
|
||||||
"""Extract {team_name: game_code} from a tab's raw rows (first game wins)."""
|
"""Return the divider role ID configured for a section title, if any.
|
||||||
games: dict[str, str] = {}
|
|
||||||
for team, _players, game in _scan_sections(rows):
|
A ``TEAM_DIVIDER_<SUFFIX>`` entry matches when every underscore-separated
|
||||||
if game and team not in games:
|
part of its suffix appears as a whole word in the title, so ``CS2`` matches
|
||||||
games[team] = game
|
a CS2 section from any year while ``CS2_2026`` matches only the 2026 one.
|
||||||
return games
|
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]:
|
def build_username_index(rosters: dict[str, list[str]]) -> dict[str, str]:
|
||||||
@@ -411,24 +439,28 @@ def build_username_index(rosters: dict[str, list[str]]) -> dict[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
def _refresh_teams_sync() -> dict[str, list[str]]:
|
def _refresh_teams_sync() -> dict[str, list[str]]:
|
||||||
global _team_roster, _team_by_username, _team_names, _team_game
|
global _team_roster, _team_by_username, _team_names, _team_divider
|
||||||
creds = Credentials.from_service_account_file(config.GOOGLE_CREDS_PATH, scopes=SCOPES)
|
creds = Credentials.from_service_account_file(config.GOOGLE_CREDS_PATH, scopes=SCOPES)
|
||||||
client = gspread.authorize(creds)
|
client = gspread.authorize(creds)
|
||||||
spreadsheet = client.open_by_key(config.TEAM_SHEET_ID)
|
spreadsheet = client.open_by_key(config.TEAM_SHEET_ID)
|
||||||
|
|
||||||
rosters: dict[str, list[str]] = {}
|
rosters: dict[str, list[str]] = {}
|
||||||
games: dict[str, str] = {}
|
dividers: dict[str, int] = {}
|
||||||
for ws in spreadsheet.worksheets():
|
for ws in spreadsheet.worksheets():
|
||||||
vals = ws.get_all_values()
|
for section in parse_team_sections(ws.get_all_values()):
|
||||||
for team, players in parse_team_rosters(vals).items():
|
# Match on the tab name too ("CS2"/"LoL"): it names the game reliably
|
||||||
rosters.setdefault(team, []).extend(players)
|
# even when the game/year title row is shadowed by notice rows, while
|
||||||
for team, game in parse_team_games(vals).items():
|
# the section title still supplies the year for year-scoped dividers.
|
||||||
games.setdefault(team, game)
|
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_roster = rosters
|
||||||
_team_by_username = build_username_index(rosters)
|
_team_by_username = build_username_index(rosters)
|
||||||
_team_names = set(rosters)
|
_team_names = set(rosters)
|
||||||
_team_game = games
|
_team_divider = dividers
|
||||||
return rosters
|
return rosters
|
||||||
|
|
||||||
|
|
||||||
@@ -453,11 +485,15 @@ def all_team_names() -> set[str]:
|
|||||||
return set(_team_names)
|
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]]:
|
def get_team_rosters() -> dict[str, list[str]]:
|
||||||
"""Current {team: [usernames]} cache (mainly for diagnostics/tests)."""
|
"""Current {team: [usernames]} cache (mainly for diagnostics/tests)."""
|
||||||
return _team_roster
|
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/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/pb_client.py` | Async PocketBase REST client - auth token cache, CRUD on `economy_users` collection |
|
||||||
| `core/sheets.py` | Google Sheets integration (member sync) |
|
| `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
|
### `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)
|
## Role Hierarchy (Discord)
|
||||||
|
|
||||||
Order top to bottom in server roles:
|
Order top to bottom in server roles:
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ CMD: dict[str, str] = {
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
OPT: dict[str, str] = {
|
OPT: dict[str, str] = {
|
||||||
"teamsync_reposition": "Paiguta ka olemasolevad tiimirollid nende mängu jaotise alla",
|
|
||||||
"admin_kasutaja": "Kasutaja",
|
"admin_kasutaja": "Kasutaja",
|
||||||
"admin_põhjus": "Põhjus (saadetakse kasutajale DM kaudu)",
|
"admin_põhjus": "Põhjus (saadetakse kasutajale DM kaudu)",
|
||||||
"admincoins_kogus": "Positiivne = anna, negatiivne = võta",
|
"admincoins_kogus": "Positiivne = anna, negatiivne = võta",
|
||||||
|
|||||||
@@ -109,11 +109,11 @@ TEAMSYNC_UI: dict[str, str] = {
|
|||||||
"assigned": "✅ Tiimirolle antud: {count}",
|
"assigned": "✅ Tiimirolle antud: {count}",
|
||||||
"removed": "➖ Tiimirolle eemaldatud: {count}",
|
"removed": "➖ Tiimirolle eemaldatud: {count}",
|
||||||
"created": "🆕 Loodud uusi tiimirolle: {roles}",
|
"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}",
|
"errors": "⚠️ Vead: {count}",
|
||||||
"no_changes": "✨ Kõik tiimirollid olid juba korras.",
|
"no_changes": "✨ Kõik tiimirollid olid juba korras.",
|
||||||
"changes_header": "**Muudatused:**",
|
"changes_header": "**Muudatused:**",
|
||||||
"changes_more": "... ja {count} rohkem",
|
"changes_more": "... ja {count} rohkem",
|
||||||
"reposition_header": "**Ümberpaigutus:**",
|
|
||||||
"repositioned": "📦 Ümber paigutatud rolle: {count}",
|
|
||||||
"reposition_none": "✨ Olemasolevad tiimirollid olid juba õiges sektsioonis.",
|
|
||||||
}
|
}
|
||||||
|
|||||||
101
tests/test_fienta.py
Normal file
101
tests/test_fienta.py
Normal file
@@ -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"
|
||||||
@@ -88,13 +88,122 @@ def test_parse_team_rosters_ignores_non_table_content():
|
|||||||
assert sheets.parse_team_rosters([["just", "some", "prose"], ["more"]]) == {}
|
assert sheets.parse_team_rosters([["just", "some", "prose"], ["more"]]) == {}
|
||||||
|
|
||||||
|
|
||||||
def test_parse_team_games_tags_each_team_with_its_section():
|
# --- parse_team_sections (which game/year a block of teams came from) ------
|
||||||
# Teams inherit the game of the stacked section they sit under.
|
|
||||||
assert sheets.parse_team_games(SHEET_ROWS) == {
|
def test_parse_team_sections_tags_each_block_with_its_title():
|
||||||
"Piirivalvurid": "CS2",
|
sections = sheets.parse_team_sections(SHEET_ROWS)
|
||||||
"GENESIS": "CS2",
|
assert [(s.title, sorted(s.rosters)) for s in sections] == [
|
||||||
"Pushing 30s": "LoL",
|
("[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():
|
def test_build_username_index_is_case_insensitive():
|
||||||
@@ -105,14 +214,9 @@ def test_build_username_index_is_case_insensitive():
|
|||||||
# --- sync_team_role behaviour (roster-independent) -------------------------
|
# --- sync_team_role behaviour (roster-independent) -------------------------
|
||||||
|
|
||||||
class FakeRole:
|
class FakeRole:
|
||||||
def __init__(self, rid: int, name: str, position: int = 0):
|
def __init__(self, rid: int, name: str):
|
||||||
self.id = rid
|
self.id = rid
|
||||||
self.name = name
|
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):
|
def __eq__(self, other):
|
||||||
return isinstance(other, FakeRole) and other.id == self.id
|
return isinstance(other, FakeRole) and other.id == self.id
|
||||||
@@ -143,9 +247,6 @@ class FakeGuild:
|
|||||||
self._next = 9000
|
self._next = 9000
|
||||||
self.created: list[str] = []
|
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):
|
async def create_role(self, name, reason=None):
|
||||||
self._next += 1
|
self._next += 1
|
||||||
role = FakeRole(self._next, name)
|
role = FakeRole(self._next, name)
|
||||||
@@ -153,6 +254,9 @@ class FakeGuild:
|
|||||||
self.created.append(name)
|
self.created.append(name)
|
||||||
return role
|
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):
|
def test_sync_creates_missing_team_role_and_removes_old_one(monkeypatch):
|
||||||
old_team = FakeRole(1, "OldTeam")
|
old_team = FakeRole(1, "OldTeam")
|
||||||
@@ -192,6 +296,43 @@ def test_sync_uses_existing_team_role(monkeypatch):
|
|||||||
assert genesis in member.roles
|
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):
|
def test_sync_strips_team_role_when_not_registered(monkeypatch):
|
||||||
old_team = FakeRole(1, "OldTeam")
|
old_team = FakeRole(1, "OldTeam")
|
||||||
member = FakeMember(1, "ghost", roles=[old_team])
|
member = FakeMember(1, "ghost", roles=[old_team])
|
||||||
@@ -240,79 +381,3 @@ def test_sync_all_team_roles_aggregates_and_skips_bots(monkeypatch):
|
|||||||
assert summary.assigned == 1
|
assert summary.assigned == 1
|
||||||
assert summary.removed == 0
|
assert summary.removed == 0
|
||||||
assert summary.changes == ["kapa: +GENESIS"]
|
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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user