Files
tipibot/config.py
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

132 lines
5.1 KiB
Python

import os
from dotenv import load_dotenv
load_dotenv()
BOT_PROFILE = os.getenv("BOT_PROFILE", "dev").strip().lower() or "dev"
if BOT_PROFILE not in {"dev", "economy"}:
raise SystemExit("BOT_PROFILE must be either 'dev' or 'economy'.")
def _env_int(name: str, default: int) -> int:
raw = os.getenv(name)
if raw is None or not raw.strip():
return default
return int(raw)
_LEGACY_DISCORD_TOKEN = os.getenv("DISCORD_TOKEN", "")
DISCORD_TOKEN_DEV = os.getenv("DISCORD_TOKEN_DEV", "")
DISCORD_TOKEN_ECONOMY = os.getenv("DISCORD_TOKEN_ECONOMY", "")
DISCORD_TOKEN = (
DISCORD_TOKEN_ECONOMY if BOT_PROFILE == "economy" else DISCORD_TOKEN_DEV
) or _LEGACY_DISCORD_TOKEN
SHEET_ID = os.getenv("SHEET_ID")
# Separate spreadsheet holding the tournament team registrations (Team Name +
# lineup of Discord usernames). Optional: when unset, team-role sync is a no-op.
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)
GUILD_ID = GUILD_ID_ECONOMY if BOT_PROFILE == "economy" else GUILD_ID_DEV
_LEGACY_BIRTHDAY_CHANNEL_ID = _env_int("BIRTHDAY_CHANNEL_ID", 0)
BIRTHDAY_CHANNEL_ID_DEV = _env_int("BIRTHDAY_CHANNEL_ID_DEV", _LEGACY_BIRTHDAY_CHANNEL_ID)
BIRTHDAY_CHANNEL_ID_ECONOMY = _env_int("BIRTHDAY_CHANNEL_ID_ECONOMY", 0)
BIRTHDAY_CHANNEL_ID = (
BIRTHDAY_CHANNEL_ID_ECONOMY
if BOT_PROFILE == "economy"
else BIRTHDAY_CHANNEL_ID_DEV
)
BIRTHDAY_WINDOW_DAYS = int(os.getenv("BIRTHDAY_WINDOW_DAYS", "7"))
BASE_ROLE_IDS: list[int] = [1478304631930228779, 1478302278862766190]
def _parse_admin_roles(raw: str) -> dict[int, set[int]]:
"""Parse DISCORD_ADMIN_ROLES env var as "guild_id:role_id[:role_id...],guild_id:role_id...".
Multiple admin roles per guild are colon-separated; guild entries are comma-separated.
Repeating a guild_id across entries merges its roles.
"""
result: dict[int, set[int]] = {}
for entry in raw.split(","):
entry = entry.strip()
if not entry:
continue
parts = entry.split(":")
if len(parts) < 2 or not all(p.strip() for p in parts):
raise SystemExit(
f"DISCORD_ADMIN_ROLES: expected 'guild_id:role_id[:role_id...]', got {entry!r}"
)
guild_id = int(parts[0].strip())
result.setdefault(guild_id, set()).update(int(p.strip()) for p in parts[1:])
return result
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_ADMIN_EMAIL = os.getenv("PB_ADMIN_EMAIL", "")
PB_ADMIN_PASSWORD = os.getenv("PB_ADMIN_PASSWORD", "")
_LEGACY_PB_COLLECTION = os.getenv("PB_ECONOMY_COLLECTION", "").strip()
PB_ECONOMY_COLLECTION_DEV = (
os.getenv("PB_ECONOMY_COLLECTION_DEV", "").strip()
or (_LEGACY_PB_COLLECTION if _LEGACY_PB_COLLECTION else "economy_users_dev")
)
PB_ECONOMY_COLLECTION_ECONOMY = (
os.getenv("PB_ECONOMY_COLLECTION_ECONOMY", "").strip()
or (_LEGACY_PB_COLLECTION if _LEGACY_PB_COLLECTION else "economy_users_prod")
)
PB_ECONOMY_COLLECTION = (
PB_ECONOMY_COLLECTION_ECONOMY if BOT_PROFILE == "economy" else PB_ECONOMY_COLLECTION_DEV
)