Merge pull request 'feat/participant-divider-roles' (#10) from feat/participant-divider-roles into master
Reviewed-on: renkar/tipibot#10
This commit is contained in:
12
.env.example
12
.env.example
@@ -10,10 +10,18 @@ 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
|
# 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
|
# 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
|
# Registration Log") - every underscore-separated part must appear in it, so
|
||||||
|
|||||||
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
|
||||||
|
|||||||
25
bot.py
25
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,35 @@ 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 or summary.positioned or summary.errors:
|
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, positioned=%d, errors=%d",
|
"team_sync_hourly: assigned=%d, removed=%d, created=%d, positioned=%d, "
|
||||||
|
"divider_assigned=%d, divider_removed=%d, errors=%d",
|
||||||
summary.assigned, summary.removed, len(summary.created),
|
summary.assigned, summary.removed, len(summary.created),
|
||||||
summary.positioned, len(summary.errors),
|
summary.positioned, summary.divider_assigned, summary.divider_removed,
|
||||||
|
len(summary.errors),
|
||||||
)
|
)
|
||||||
for err in summary.errors:
|
for err in summary.errors:
|
||||||
log.warning("team_sync_hourly: %s", err)
|
log.warning("team_sync_hourly: %s", err)
|
||||||
@@ -472,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,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,24 +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, positioned=%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.positioned,
|
||||||
|
summary.divider_assigned,
|
||||||
|
summary.divider_removed,
|
||||||
len(summary.errors),
|
len(summary.errors),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -74,6 +78,10 @@ def _format_summary(summary) -> str:
|
|||||||
lines.append(S.TEAMSYNC_UI["created"].format(roles=", ".join(unique)))
|
lines.append(S.TEAMSYNC_UI["created"].format(roles=", ".join(unique)))
|
||||||
if summary.positioned:
|
if summary.positioned:
|
||||||
lines.append(S.TEAMSYNC_UI["positioned"].format(count=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)))
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
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
|
||||||
@@ -80,6 +105,8 @@ class TeamSyncSummary:
|
|||||||
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
|
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)
|
||||||
|
|
||||||
@@ -260,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:
|
||||||
@@ -307,6 +337,35 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -369,8 +428,8 @@ async def apply_team_role_positions(
|
|||||||
downstream is name-based. Returns ``(roles_moved, errors)``; a no-op returns
|
downstream is name-based. Returns ``(roles_moved, errors)``; a no-op returns
|
||||||
``(0, [])``.
|
``(0, [])``.
|
||||||
"""
|
"""
|
||||||
team_dividers = sheets.get_team_dividers() # {team: divider role ID}
|
team_divider_ids = team_dividers() # {team: divider role ID}, both sources
|
||||||
if not team_dividers:
|
if not team_divider_ids:
|
||||||
return 0, [] # no dividers configured, or nothing matched
|
return 0, [] # no dividers configured, or nothing matched
|
||||||
|
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
@@ -378,7 +437,7 @@ async def apply_team_role_positions(
|
|||||||
# that role's current name for the name-based positioning maths below.
|
# that role's current name for the name-based positioning maths below.
|
||||||
placements: dict[str, list[str]] = {}
|
placements: dict[str, list[str]] = {}
|
||||||
resolved: dict[int, discord.Role | None] = {}
|
resolved: dict[int, discord.Role | None] = {}
|
||||||
for team, divider_id in team_dividers.items():
|
for team, divider_id in team_divider_ids.items():
|
||||||
if divider_id not in resolved:
|
if divider_id not in resolved:
|
||||||
resolved[divider_id] = guild.get_role(divider_id)
|
resolved[divider_id] = guild.get_role(divider_id)
|
||||||
if resolved[divider_id] is None:
|
if resolved[divider_id] is None:
|
||||||
@@ -421,6 +480,16 @@ async def apply_team_role_positions(
|
|||||||
return 0, errors
|
return 0, errors
|
||||||
except discord.HTTPException as e:
|
except discord.HTTPException as e:
|
||||||
errors.append(f"Tiimirollide järjestamine ebaõnnestus: {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
|
return 0, errors
|
||||||
log.info("Positioned %d team role(s) under their dividers", len(positions))
|
log.info("Positioned %d team role(s) under their dividers", len(positions))
|
||||||
return len(positions), errors
|
return len(positions), errors
|
||||||
@@ -449,12 +518,20 @@ 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
|
# Placement runs after the grant/remove pass so roles created this run are
|
||||||
|
|||||||
@@ -110,6 +110,8 @@ TEAMSYNC_UI: dict[str, str] = {
|
|||||||
"removed": "➖ Tiimirolle eemaldatud: {count}",
|
"removed": "➖ Tiimirolle eemaldatud: {count}",
|
||||||
"created": "🆕 Loodud uusi tiimirolle: {roles}",
|
"created": "🆕 Loodud uusi tiimirolle: {roles}",
|
||||||
"positioned": "📍 Eraldaja alla paigutatud: {count}",
|
"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
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"
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -253,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")
|
||||||
@@ -292,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])
|
||||||
|
|||||||
Reference in New Issue
Block a user