8 Commits

Author SHA1 Message Date
Rene Arumetsa
fb08bc56d7 chore(teams): log role batch vs bot_top when edit_role_positions fails
A 50013 on positioning despite Manage Roles points to a role in the reorder
batch sitting at/above the bot's top role. Log the batch and bot_top so the
offending role is identifiable from the journal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPRsW4tazVtYi2jNzVQkre
2026-09-04 00:54:00 +03:00
Rene Arumetsa
a280ba05cc feat(teams): add Fienta as primary Discord->team source (sheet fallback)
The registration-log sheet only has in-game nicknames, so matching Discord
users to teams failed for ~10 of 40 teams. Fienta collects each competitor's
Discord username (+ sometimes user ID) and team name per ticket, giving a
reliable Discord-identity -> team mapping (validated: 201 usernames, 42 teams).

- core/fienta.py: token-auth client; fetch /events/{id}/tickets?attendees=true,
  parse competitor/coach/substitute tickets into {username|id -> team} and
  {team -> game}; exclude visitor/supporter/LAN/early-bird/waiting-list. No-op
  when FIENTA_API_TOKEN/FIENTA_EVENT_ID unset.
- member_sync: resolve_team() tries Fienta (id, then username) then the sheet;
  all_managed_team_names() and team_dividers() merge both sources.
- /teamsync + hourly task refresh Fienta alongside the sheet; enabled when
  either source is configured.
- config + .env.example: FIENTA_API_TOKEN, FIENTA_EVENT_ID.
- tests: fienta parsing (game detection, inclusion rules, id/username mapping).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPRsW4tazVtYi2jNzVQkre
2026-09-04 00:50:54 +03:00
Rene Arumetsa
3c8927184b feat(teams): grant each participant their game's divider role
Reuse the existing CS2/LoL divider roles as participant tags: sync_team_role
now also adds the divider role for the member's team's game, and strips any
other configured divider role (switched game / dropped out). Matched by ID
against config.TEAM_DIVIDERS, so only the divider roles are ever touched.

- TeamSyncResult gains divider_added / divider_removed; summary tallies both
- /teamsync report and log line surface divider_assigned / divider_removed
- tests: grants the game divider, swaps it on a game switch

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPRsW4tazVtYi2jNzVQkre
2026-09-04 00:00:41 +03:00
b813ed5f81 Merge pull request 'fix(teams): resolve dividers from tab name + full title, not last notice row' (#9) from feat/teamsync-reposition into master
All checks were successful
Test & Deploy / test (push) Successful in 5s
Test & Deploy / deploy (push) Successful in 7s
Reviewed-on: #9
2026-09-03 20:35:24 +00:00
Rene Arumetsa
c19e67b5ab fix(teams): resolve dividers from tab name + full title, not last notice row
Section titles are stacked single-cell rows: the "TipiLAN 2026 CS2" title
sits above notice rows ("If a team withdraws..."), and parse_team_sections
kept only the LAST one, so the notice clobbered the title and resolve_divider
saw no game/year keywords -> None -> teams never positioned.

- parse_team_sections now accumulates all single-cell rows above a header, so
  the game/year title survives alongside the notices.
- _refresh_teams_sync resolves the divider from "<tab name> <section title>",
  so the game is taken reliably from the CS2/LoL worksheet name while the year
  still comes from the title, keeping year-scoped TEAM_DIVIDER_*_2026 vars.
- regression test: title survives notice rows and still resolves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPRsW4tazVtYi2jNzVQkre
2026-09-03 23:32:58 +03:00
b20bca9244 Merge pull request 'feat(teams): resolve team dividers by role ID instead of name' (#8) from feat/teamsync-reposition into master
All checks were successful
Test & Deploy / test (push) Successful in 5s
Test & Deploy / deploy (push) Successful in 6s
Reviewed-on: #8
2026-09-03 19:38:23 +00:00
Rene Arumetsa
d48a436e26 feat(teams): resolve team dividers by role ID instead of name
Divider placement matched the divider role by its exact Discord name, so
renaming the role in Discord silently broke positioning. Switch the
TEAM_DIVIDER_<SUFFIX> config to hold a role ID; resolve the ID to the
role's current name in apply_team_role_positions and keep the existing
name-based ordering maths downstream unchanged.

- config._parse_team_dividers now parses values as ints (rejects non-ints)
- resolve_divider / _team_divider cache / get_team_dividers return IDs
- apply_team_role_positions resolves each ID via guild.get_role once
- .env.example documents IDs and ships the CS2/LoL divider role IDs
- resolve_divider tests updated to assert IDs

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPRsW4tazVtYi2jNzVQkre
2026-09-03 22:34:34 +03:00
Rene Arumetsa
deab0f7a55 CS2 team dividers.
All checks were successful
Test & Deploy / test (push) Successful in 7s
Test & Deploy / deploy (push) Successful in 7s
2026-09-03 21:36:41 +03:00
13 changed files with 930 additions and 33 deletions

View File

@@ -10,10 +10,28 @@ 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=
# Fienta ticketing - PRIMARY source for team-role sync. The registration collects
# each competitor's Discord username + team name per ticket, so matching is by
# real Discord handle (not game nickname). Get an API token from the Fienta admin
# (organizer settings) and the event's numeric ID from its dashboard URL. Leave
# unset to use only the sheet.
FIENTA_API_TOKEN=
FIENTA_EVENT_ID=
# Where each game's team roles get positioned in the role list. The key suffix
# is matched against the section's title row in the sheet ("TipiLAN 2026 CS2
# Registration Log") - every underscore-separated part must appear in it, so
# CS2_2026 matches only the 2026 CS2 block while a plain CS2 would match any
# year. The value is the Discord role ID to place those teams under (matching by
# ID means renaming the divider role never breaks positioning).
# Optional: sections that match nothing still get their roles, just unpositioned.
TEAM_DIVIDER_CS2_2026=1498736834656604251
TEAM_DIVIDER_LOL_2026=1498736949706490017
# Path to Google service account credentials JSON # Path to Google service account credentials JSON
GOOGLE_CREDS_PATH=credentials.json GOOGLE_CREDS_PATH=credentials.json

3
.gitignore vendored
View File

@@ -10,3 +10,6 @@ pocketbase
pb_data/ pb_data/
pb_migrations/ pb_migrations/
logs/ logs/
fientalog
fientatickets
fientaorders

View File

@@ -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
View File

@@ -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)")

View File

@@ -14,7 +14,7 @@ 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 sync_all_team_roles from core.member_sync import sync_all_team_roles
import strings as S import strings as S
@@ -38,23 +38,28 @@ 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)
await interaction.followup.send(_format_summary(summary), ephemeral=True) await interaction.followup.send(_format_summary(summary), ephemeral=True)
log.info( log.info(
"/teamsync - scanned=%d, assigned=%d, removed=%d, created=%d, errors=%d", "/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),
) )
@@ -71,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)))

View File

@@ -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)
@@ -69,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
View 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
}

View File

@@ -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,6 +104,9 @@ 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)
@@ -259,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:
@@ -306,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,
@@ -332,13 +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)}")
# 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 return summary

View File

@@ -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
@@ -279,6 +280,19 @@ _LINEUP_HEADER_PREFIX = "lineup"
_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_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]:
@@ -321,22 +335,41 @@ 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 parse_team_rosters(rows: list[list]) -> dict[str, list[str]]: def _merged_title(row: list) -> str | None:
"""Extract {team_name: [nickname, ...]} from a tab's raw rows. """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...' 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. 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) 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:
if (text := _merged_title(rows[i])) is not None:
# Accumulate every single-cell row above the header, not just the
# last one: sheets stack the "TipiLAN 2026 CS2" title above notice
# rows ("If a team withdraws..."), and the later notices must not
# clobber the title whose keywords resolve_divider needs.
title = f"{title} {text}".strip() if title else text
i += 1 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()
@@ -346,9 +379,44 @@ def parse_team_rosters(rows: list[list]) -> dict[str, list[str]]:
if players: if players:
rosters.setdefault(team, []).extend(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]]:
"""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 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]: def build_username_index(rosters: dict[str, list[str]]) -> dict[str, str]:
"""Invert {team: [usernames]} into {normalized username: team}. """Invert {team: [usernames]} into {normalized username: team}.
@@ -371,19 +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 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]] = {}
dividers: dict[str, int] = {}
for ws in spreadsheet.worksheets(): 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()):
rosters.setdefault(team, []).extend(players) # Match on the tab name too ("CS2"/"LoL"): it names the game reliably
# even when the game/year title row is shadowed by notice rows, while
# the section title still supplies the year for year-scoped dividers.
divider = resolve_divider(f"{ws.title} {section.title}")
for team, players in section.rosters.items():
rosters.setdefault(team, []).extend(players)
if divider:
dividers[team] = divider
_team_roster = rosters _team_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_divider = dividers
return rosters return rosters
@@ -411,3 +488,12 @@ def all_team_names() -> set[str]:
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)

View File

@@ -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:

View File

@@ -109,6 +109,9 @@ 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:**",

101
tests/test_fienta.py Normal file
View 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"

View File

@@ -13,6 +13,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
import config # noqa: E402
from core import member_sync, sheets # noqa: E402 from core import member_sync, sheets # noqa: E402
from tests.conftest import run # noqa: E402 from tests.conftest import run # noqa: E402
@@ -87,6 +88,124 @@ 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"]]) == {}
# --- parse_team_sections (which game/year a block of teams came from) ------
def test_parse_team_sections_tags_each_block_with_its_title():
sections = sheets.parse_team_sections(SHEET_ROWS)
assert [(s.title, sorted(s.rosters)) for s in sections] == [
("[merged] TipiLAN 2026 CS2 Registration Log", ["GENESIS", "Piirivalvurid"]),
("[merged] TipiLAN 2026 LoL Registration Log", ["Pushing 30s"]),
]
def test_parse_team_sections_title_does_not_leak_to_untitled_block():
rows = [
["[merged] TipiLAN 2026 CS2 Registration Log"] + [""] * 2,
["No", "Team Name", "Lineup (nickname)"],
["1", "GENESIS", "kapa (EST)"],
["", "", ""],
["No", "Team Name", "Lineup (nickname)"], # second block, no title above it
["1", "Nameless", "someone (EST)"],
]
titles = [s.title for s in sheets.parse_team_sections(rows)]
assert titles == ["[merged] TipiLAN 2026 CS2 Registration Log", ""]
# --- resolve_divider (section title -> configured divider role) ------------
DIVIDERS = {
"cs2_2026": 1498736834656604251,
"lol_2026": 1498736949706490017,
}
def test_resolve_divider_matches_game_and_year():
assert sheets.resolve_divider(
"[merged] TipiLAN 2026 CS2 Registration Log", DIVIDERS
) == 1498736834656604251
assert sheets.resolve_divider(
"[merged] TipiLAN 2026 LoL Registration Log", DIVIDERS
) == 1498736949706490017
def test_resolve_divider_year_scoped_key_ignores_other_years():
assert sheets.resolve_divider("TipiLAN 2025 CS2 Registration Log", DIVIDERS) is None
def test_resolve_divider_prefers_the_most_specific_match():
dividers = {"cs2": 111, "cs2_2026": 222}
assert sheets.resolve_divider("TipiLAN 2026 CS2 Log", dividers) == 222
assert sheets.resolve_divider("TipiLAN 2025 CS2 Log", dividers) == 111
def test_resolve_divider_no_config_or_no_title_is_none():
assert sheets.resolve_divider("TipiLAN 2026 CS2 Log", {}) is None
assert sheets.resolve_divider("", DIVIDERS) is None
def test_title_survives_notice_rows_below_it():
# Real sheets stack the game/year title above single-cell notice rows; the
# title's keywords must survive so resolve_divider still matches (regression:
# notice rows used to overwrite the title, yielding a None divider).
rows = [
["TipiLAN 2026 CS2 Registration Log"] + [""] * 4,
["This log is updated automatically."] + [""] * 4,
["If a team from the Top 32 withdraws, ..."] + [""] * 4,
["No", "Team Name", "Lineup (nickname, citizenship)", "", ""],
["1", "GENESIS", "kapa (EST)", "", ""],
]
section = sheets.parse_team_sections(rows)[0]
assert "CS2" in section.title and "2026" in section.title
assert sheets.resolve_divider(section.title, DIVIDERS) == 1498736834656604251
# --- plan_team_positions (pure role-ordering maths) ------------------------
def test_plan_moves_teams_directly_under_their_divider():
# Ascending = bottom-up, so the Discord UI renders this list reversed.
ordered = ["@everyone", "60hz", "999", "Raid", "CS2-2026", "LOL-2026", "Bot"]
plan = member_sync.plan_team_positions(
ordered, {"Bot"}, {"CS2-2026": ["60hz", "999", "Raid"]}
)
final = sorted(set(ordered), key=lambda n: plan.get(n, ordered.index(n)))
# Teams end up just below the divider, alphabetical downwards in the UI.
assert final == ["@everyone", "Raid", "999", "60hz", "CS2-2026", "LOL-2026", "Bot"]
def test_plan_groups_each_divider_separately():
ordered = ["@everyone", "Alpha", "Pushing 30s", "CS2-2026", "LOL-2026", "Bot"]
plan = member_sync.plan_team_positions(
ordered,
{"Bot"},
{"CS2-2026": ["Alpha"], "LOL-2026": ["Pushing 30s"]},
)
final = sorted(set(ordered), key=lambda n: plan.get(n, ordered.index(n)))
assert final == ["@everyone", "Alpha", "CS2-2026", "Pushing 30s", "LOL-2026", "Bot"]
def test_plan_is_a_noop_when_already_positioned():
ordered = ["@everyone", "Raid", "999", "60hz", "CS2-2026", "Bot"]
assert member_sync.plan_team_positions(
ordered, {"Bot"}, {"CS2-2026": ["60hz", "999", "Raid"]}
) == {}
def test_plan_skips_missing_divider_and_leaves_its_teams_alone():
ordered = ["@everyone", "60hz", "CS2-2026", "Bot"]
plan = member_sync.plan_team_positions(
ordered, {"Bot"}, {"LOL-2026": ["60hz"]} # divider not in the guild
)
assert plan == {}
def test_plan_never_moves_managed_roles():
ordered = ["@everyone", "60hz", "CS2-2026", "Nitro", "Bot"]
plan = member_sync.plan_team_positions(
ordered, {"Nitro", "Bot"}, {"CS2-2026": ["60hz", "Nitro"]}
)
assert "Nitro" not in plan and "Bot" not in plan
def test_build_username_index_is_case_insensitive(): def test_build_username_index_is_case_insensitive():
index = sheets.build_username_index({"GENESIS": ["Kapa", "neaQ"]}) index = sheets.build_username_index({"GENESIS": ["Kapa", "neaQ"]})
assert index == {"kapa": "GENESIS", "neaq": "GENESIS"} assert index == {"kapa": "GENESIS", "neaq": "GENESIS"}
@@ -135,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")
@@ -174,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])