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

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