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
This commit is contained in:
Rene Arumetsa
2026-09-04 00:50:54 +03:00
parent 3c8927184b
commit a280ba05cc
8 changed files with 361 additions and 23 deletions

View File

@@ -10,10 +10,18 @@ DISCORD_TOKEN=
SHEET_ID=your-google-sheet-id-here
# Separate spreadsheet holding tournament team registrations (Team Name + lineup
# of Discord usernames). Optional; drives /teamsync + hourly team-role sync on
# the economy/community bot. Leave unset to disable team-role sync entirely.
# of Discord usernames). Optional; a FALLBACK source for /teamsync + hourly
# team-role sync on the economy/community bot. Leave unset to disable it.
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

3
.gitignore vendored
View File

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

25
bot.py
View File

@@ -21,7 +21,7 @@ import psutil
import config
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.member_sync import SyncResult, sync_all_team_roles
from commands.dev_member_commands import register_dev_member_commands
@@ -339,31 +339,35 @@ async def before_birthday_daily():
@tasks.loop(hours=1)
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).
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
try:
rosters = await sheets.refresh_teams()
fienta_teams = await fienta.refresh_teams()
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
if not rosters:
if not rosters and not fienta_teams:
return
guild = bot.get_guild(config.GUILD_ID)
if guild is None:
log.warning("team_sync_hourly: guild %s not found", config.GUILD_ID)
return
summary = await sync_all_team_roles(guild, log)
if summary.assigned or summary.removed or summary.created 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(
"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.positioned, len(summary.errors),
summary.positioned, summary.divider_assigned, summary.divider_removed,
len(summary.errors),
)
for err in summary.errors:
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)")
# 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()
log.info("Team-role sync task started (hourly, from the registration sheet)")

View File

@@ -14,7 +14,7 @@ import logging
import discord
from discord import app_commands
from core import sheets
from core import fienta, sheets
from core.admin import bot_admin_check
from core.member_sync import sync_all_team_roles
import strings as S
@@ -38,12 +38,13 @@ def register_economy_team_commands(
try:
rosters = await sheets.refresh_teams()
fienta_teams = await fienta.refresh_teams()
except Exception as e:
await interaction.followup.send(
S.TEAMSYNC_UI["refresh_error"].format(error=e), ephemeral=True
)
return
if not rosters:
if not rosters and not fienta_teams:
await interaction.followup.send(S.TEAMSYNC_UI["disabled"], ephemeral=True)
return

View File

@@ -28,6 +28,13 @@ SHEET_ID = os.getenv("SHEET_ID")
TEAM_SHEET_ID = os.getenv("TEAM_SHEET_ID")
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)
GUILD_ID_DEV = _env_int("GUILD_ID_DEV", _LEGACY_GUILD_ID)
GUILD_ID_ECONOMY = _env_int("GUILD_ID_ECONOMY", _LEGACY_GUILD_ID)

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 config
from . import sheets
from . import fienta, sheets
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"}
_TZ = ZoneInfo("Europe/Tallinn")
@@ -275,10 +298,10 @@ async def sync_team_role(
"""
result = TeamSyncResult()
team_name = sheets.get_team_for_username(member.name)
all_teams = sheets.all_team_names()
team_name = resolve_team(member)
all_teams = all_managed_team_names()
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
if team_name:
@@ -319,7 +342,7 @@ async def sync_team_role(
# 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 = sheets.get_team_dividers().get(team_name) if team_name else None
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:
@@ -405,8 +428,8 @@ async def apply_team_role_positions(
downstream is name-based. Returns ``(roles_moved, errors)``; a no-op returns
``(0, [])``.
"""
team_dividers = sheets.get_team_dividers() # {team: divider role ID}
if not team_dividers:
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] = []
@@ -414,7 +437,7 @@ async def apply_team_role_positions(
# that role's current name for the name-based positioning maths below.
placements: dict[str, list[str]] = {}
resolved: dict[int, discord.Role | None] = {}
for team, divider_id in team_dividers.items():
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:

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"