From 3c2b4342a2fb7710d02ea6f51892c77436d70cb5 Mon Sep 17 00:00:00 2001 From: AlacrisDevs Date: Wed, 29 Apr 2026 22:38:47 +0300 Subject: [PATCH] Added Fienta integration --- .env.example | 14 + README.md | 11 +- bot.py | 93 +++- commands/lan_fienta_commands.py | 28 ++ compose.yaml | 2 + config.py | 47 +- core/lan_fienta.py | 868 ++++++++++++++++++++++++++++++++ core/pb_client.py | 100 +++- docs/LAN_FIENTA_SETUP.md | 79 +++ scripts/reset_pb_collections.py | 78 ++- ssssecret.txt | 43 ++ strings.py | 2 + 12 files changed, 1336 insertions(+), 29 deletions(-) create mode 100644 commands/lan_fienta_commands.py create mode 100644 core/lan_fienta.py create mode 100644 docs/LAN_FIENTA_SETUP.md create mode 100644 ssssecret.txt diff --git a/.env.example b/.env.example index ca588c6..9881fb7 100644 --- a/.env.example +++ b/.env.example @@ -2,12 +2,15 @@ # Profile-specific Discord bot tokens (from https://discord.com/developers/applications) DISCORD_TOKEN_DEV=your-dev-bot-token-here DISCORD_TOKEN_ECONOMY=your-economy-bot-token-here +DISCORD_BOT_LAN=your-lan-bot-token-here # Legacy fallback token (optional, backward compatibility) DISCORD_TOKEN= # Google Sheets spreadsheet ID (the long string in the sheet URL) SHEET_ID=your-google-sheet-id-here +SHEET_ID_DEV= +SHEET_ID_LAN=1cYEI2EmQDMZdVOarbOkVw7IHsnfqtYbWhA-6zC_r-zw # Path to Google service account credentials JSON GOOGLE_CREDS_PATH=credentials.json @@ -15,6 +18,7 @@ GOOGLE_CREDS_PATH=credentials.json # Profile-specific guild (server) IDs - right-click your server with dev mode on GUILD_ID_DEV=your-dev-guild-id-here GUILD_ID_ECONOMY=your-economy-guild-id-here +GUILD_ID_LAN=1301145356750426192 # Legacy fallback guild ID (optional, backward compatibility) GUILD_ID= @@ -39,6 +43,16 @@ PB_ADMIN_PASSWORD=your-pb-admin-password # Profile-specific PocketBase collections PB_ECONOMY_COLLECTION_DEV=economy_users_dev PB_ECONOMY_COLLECTION_ECONOMY=economy_users_prod +PB_ECONOMY_COLLECTION_LAN=economy_users_lan +PB_FIENTA_COLLECTION_LAN=fienta_registrations_lan # Legacy fallback collection name (optional, backward compatibility) PB_ECONOMY_COLLECTION= + +# Fienta LAN registration sync +# Fienta production URLs: +# https://veebikonks.tipilan.ee/fienta/purchase +# https://veebikonks.tipilan.ee/fienta/registration +FIENTA_WEBHOOK_SECRET=optional-secret-for-/fienta/webhook +FIENTA_WEBHOOK_PORT=8090 +FIENTA_ADMIN_ALERT_CHANNEL_ID=1478302279894302812 diff --git a/README.md b/README.md index 492522a..6a0fd7d 100644 --- a/README.md +++ b/README.md @@ -88,14 +88,18 @@ cp .env.example .env | Variable | Description | |---|---| -| `BOT_PROFILE` | Runtime profile: `dev` or `economy` | +| `BOT_PROFILE` | Runtime profile: `dev`, `economy`, or `lan` | | `DISCORD_TOKEN_DEV` | Dev bot token from Discord Developer Portal | | `DISCORD_TOKEN_ECONOMY` | Economy bot token from Discord Developer Portal | +| `DISCORD_BOT_LAN` | LAN bot token from Discord Developer Portal | | `DISCORD_TOKEN` | Legacy fallback token (optional) | | `SHEET_ID` | ID from the Google Sheet URL | +| `SHEET_ID_DEV` | Optional dev/member sheet override | +| `SHEET_ID_LAN` | LAN public registration sheet ID | | `GOOGLE_CREDS_PATH` | Path to `credentials.json` (default: `credentials.json`) | | `GUILD_ID_DEV` | Dev bot guild ID | | `GUILD_ID_ECONOMY` | Economy bot guild ID | +| `GUILD_ID_LAN` | LAN bot guild ID | | `GUILD_ID` | Legacy fallback guild ID (optional) | | `BIRTHDAY_CHANNEL_ID_DEV` | Channel for birthday `@here` pings in dev profile | | `BIRTHDAY_CHANNEL_ID_ECONOMY` | Optional birthday channel in economy profile | @@ -106,7 +110,12 @@ cp .env.example .env | `PB_ADMIN_PASSWORD` | PocketBase superuser password | | `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_LAN` | PocketBase economy collection used by `BOT_PROFILE=lan` | +| `PB_FIENTA_COLLECTION_LAN` | PocketBase collection for LAN Fienta registration records | | `PB_ECONOMY_COLLECTION` | Legacy fallback collection (optional) | +| `FIENTA_WEBHOOK_SECRET` | Optional secret path token for `/fienta/webhook/` testing | +| `FIENTA_WEBHOOK_PORT` | LAN Fienta webhook listen port (default: `8090`) | +| `FIENTA_ADMIN_ALERT_CHANNEL_ID` | Discord channel for LAN Fienta sync alerts | ### 6. Install & Run diff --git a/bot.py b/bot.py index 5e33873..b4b5a6b 100644 --- a/bot.py +++ b/bot.py @@ -18,10 +18,11 @@ from discord.ext import tasks import colorlog import psutil +from aiohttp import web import config import strings as S -from core import economy, pb_client, sheets +from core import economy, lan_fienta, pb_client, sheets from core.member_sync import SyncResult from commands.dev_member_commands import register_dev_member_commands from commands.dev_member_runtime import handle_member_join, run_birthday_daily @@ -33,6 +34,7 @@ from commands.economy_income_commands import register_economy_income_commands from commands.economy_prestige_commands import register_prestige_commands from commands.economy_profile_commands import register_economy_profile_commands from commands.economy_support_commands import register_economy_support_commands +from commands.lan_fienta_commands import register_lan_fienta_commands from commands.ops_channel_commands import register_ops_channel_commands from commands.ops_admin_commands import register_ops_admin_commands @@ -94,6 +96,7 @@ tree = app_commands.CommandTree(bot) GUILD_OBJ = discord.Object(id=config.GUILD_ID) IS_DEV_PROFILE = config.BOT_PROFILE == "dev" +IS_LAN_PROFILE = config.BOT_PROFILE == "lan" TALLINN_TZ = ZoneInfo("Europe/Tallinn") _start_time = datetime.datetime.now() _process = psutil.Process() @@ -112,6 +115,8 @@ _RESTART_FILE = _DATA_DIR / "restart_channel.json" _BOT_CONFIG = _DATA_DIR / "bot_config.json" _PAUSED = False # maintenance mode: blocks non-admin commands when True _DEV_ONLY_COMMANDS: tuple[str, ...] = ("birthdays", "check", "member") +_LAN_ONLY_COMMANDS: tuple[str, ...] = ("fientasync",) +_FIENTA_RUNNER: web.AppRunner | None = None def _apply_profile_command_filters() -> None: @@ -161,6 +166,64 @@ def _member_cache_size() -> int: return len(sheets.get_cache()) +# --------------------------------------------------------------------------- +# Fienta webhook server (LAN profile) +# --------------------------------------------------------------------------- +async def _fienta_health(_: web.Request) -> web.Response: + return web.json_response({"ok": True, "profile": config.BOT_PROFILE}) + + +async def _process_fienta_payload(payload: dict, source: str) -> None: + try: + summary = await lan_fienta.process_payload(bot, payload) + log.info("Fienta %s webhook processed: %s", source, summary.short()) + except Exception as exc: + log.exception("Fienta %s webhook processing failed: %s", source, exc) + + +async def _accept_fienta_payload(request: web.Request, source: str) -> web.Response: + try: + payload = await request.json() + except Exception: + return web.json_response({"ok": False, "error": "invalid JSON"}, status=400) + asyncio.create_task(_process_fienta_payload(payload, source)) + return web.json_response({"ok": True, "accepted": True, "source": source}) + + +async def _handle_fienta_secret_webhook(request: web.Request) -> web.Response: + if not config.FIENTA_WEBHOOK_SECRET: + return web.json_response({"ok": False, "error": "webhook secret not configured"}, status=503) + if request.match_info.get("secret") != config.FIENTA_WEBHOOK_SECRET: + return web.json_response({"ok": False, "error": "not found"}, status=404) + return await _accept_fienta_payload(request, "secret") + + +async def _handle_fienta_purchase(request: web.Request) -> web.Response: + return await _accept_fienta_payload(request, "purchase") + + +async def _handle_fienta_registration(request: web.Request) -> web.Response: + return await _accept_fienta_payload(request, "registration") + + +async def _start_fienta_webhook() -> None: + global _FIENTA_RUNNER + if not IS_LAN_PROFILE or _FIENTA_RUNNER is not None: + return + app = web.Application(client_max_size=5 * 1024 * 1024) + app.router.add_get("/health", _fienta_health) + app.router.add_post("/fienta/purchase", _handle_fienta_purchase) + app.router.add_post("/fienta/registration", _handle_fienta_registration) + app.router.add_post("/fienta/webhook/{secret}", _handle_fienta_secret_webhook) + + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, "0.0.0.0", config.FIENTA_WEBHOOK_PORT) + await site.start() + _FIENTA_RUNNER = runner + log.info("LAN Fienta webhook listening on 0.0.0.0:%s", config.FIENTA_WEBHOOK_PORT) + + # --------------------------------------------------------------------------- # EXP / Level role helpers # --------------------------------------------------------------------------- @@ -389,6 +452,13 @@ async def on_ready(): log.info("Loaded %d member rows from Google Sheets", len(data)) except Exception as e: log.error("Failed to load sheet on startup: %s", e) + elif IS_LAN_PROFILE: + try: + created = await lan_fienta.ensure_storage() + if created: + log.info("Created LAN Fienta PocketBase collection '%s'", config.PB_FIENTA_COLLECTION_LAN) + except Exception as e: + log.error("Failed to prepare LAN Fienta storage: %s", e) # Sync slash commands to the guild only; wipe any leftover global registrations tree.copy_global_to(guild=GUILD_OBJ) @@ -407,6 +477,10 @@ async def on_ready(): _rotate_presence.start() log.info("Rich presence rotation started") + # Start Fienta webhook for LAN registration sync + if IS_LAN_PROFILE: + await _start_fienta_webhook() + # Re-schedule any reminder tasks lost on restart await _restore_reminders() @@ -436,6 +510,11 @@ async def on_resumed(): @bot.event async def on_member_join(member: discord.Member): """When someone joins, look them up in the sheet and sync.""" + if IS_LAN_PROFILE: + summary = await lan_fienta.sync_member_join(bot, member) + if summary.roles_synced or summary.alerts: + log.info("LAN join Fienta sync for %s: %s", member, summary.short()) + return if not IS_DEV_PROFILE: return await handle_member_join( @@ -460,6 +539,9 @@ if IS_DEV_PROFILE: mark_announced_today=_mark_announced_today, ) +if IS_LAN_PROFILE: + register_lan_fienta_commands(tree, bot, log) + register_ops_admin_commands( tree, bot, @@ -494,6 +576,7 @@ async def cmd_ping(interaction: discord.Interaction): # --------------------------------------------------------------------------- _HELP_PAGE_SIZE = 10 _DEV_ONLY_HELP_TOKENS: tuple[str, ...] = tuple(f"/{name}" for name in _DEV_ONLY_COMMANDS) +_LAN_ONLY_HELP_TOKENS: tuple[str, ...] = tuple(f"/{name}" for name in _LAN_ONLY_COMMANDS) def _visible_help_fields(category_key: str) -> list[tuple[str, str]]: @@ -506,6 +589,8 @@ def _visible_help_fields(category_key: str) -> list[tuple[str, str]]: blob = f"{name}\n{value}".lower() if any(tok in blob for tok in _DEV_ONLY_HELP_TOKENS): continue + if not IS_LAN_PROFILE and any(tok in blob for tok in _LAN_ONLY_HELP_TOKENS): + continue visible.append((name, value)) return visible @@ -881,7 +966,11 @@ def _asyncio_exception_handler(loop: asyncio.AbstractEventLoop, context: dict) - if __name__ == "__main__": if not config.DISCORD_TOKEN: - profile_key = "DISCORD_TOKEN_ECONOMY" if config.BOT_PROFILE == "economy" else "DISCORD_TOKEN_DEV" + profile_key = { + "dev": "DISCORD_TOKEN_DEV", + "economy": "DISCORD_TOKEN_ECONOMY", + "lan": "DISCORD_BOT_LAN", + }[config.BOT_PROFILE] raise SystemExit( f"{profile_key} pole seadistatud profiilile '{config.BOT_PROFILE}'. " "Kopeeri .env.example failiks .env ja täida see." diff --git a/commands/lan_fienta_commands.py b/commands/lan_fienta_commands.py new file mode 100644 index 0000000..8d609a6 --- /dev/null +++ b/commands/lan_fienta_commands.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import logging + +import discord +from discord import app_commands + +from core import lan_fienta +import strings as S + + +def register_lan_fienta_commands( + tree: app_commands.CommandTree, + bot: discord.Client, + log: logging.Logger, +) -> None: + @tree.command(name="fientasync", description=S.CMD["fientasync"]) + @app_commands.guild_only() + @app_commands.default_permissions(manage_guild=True) + async def cmd_fientasync(interaction: discord.Interaction): + await interaction.response.defer(ephemeral=True) + try: + summary = await lan_fienta.resync_all(bot) + except Exception as exc: + log.exception("/fientasync failed") + await interaction.followup.send(f"❌ Fienta sync failed: `{exc}`", ephemeral=True) + return + await interaction.followup.send(f"✅ Fienta sync done: `{summary.short()}`", ephemeral=True) diff --git a/compose.yaml b/compose.yaml index 1869b5d..ecf4dcd 100644 --- a/compose.yaml +++ b/compose.yaml @@ -24,6 +24,8 @@ services: - .env environment: - PB_URL=http://pocketbase:8090 + expose: + - "8090" volumes: - ./data:/app/data - ./logs:/app/logs diff --git a/config.py b/config.py index b3d04bb..c9a33ce 100644 --- a/config.py +++ b/config.py @@ -4,8 +4,8 @@ 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'.") +if BOT_PROFILE not in {"dev", "economy", "lan"}: + raise SystemExit("BOT_PROFILE must be either 'dev', 'economy', or 'lan'.") def _env_int(name: str, default: int) -> int: @@ -18,17 +18,31 @@ def _env_int(name: str, default: int) -> int: _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 +DISCORD_BOT_LAN = os.getenv("DISCORD_BOT_LAN", "") +DISCORD_TOKEN = { + "dev": DISCORD_TOKEN_DEV, + "economy": DISCORD_TOKEN_ECONOMY, + "lan": DISCORD_BOT_LAN, +}[BOT_PROFILE] or _LEGACY_DISCORD_TOKEN -SHEET_ID = os.getenv("SHEET_ID") +SHEET_ID_DEV = os.getenv("SHEET_ID_DEV", "").strip() +SHEET_ID_LAN = os.getenv("SHEET_ID_LAN", "").strip() +SHEET_ID = ( + SHEET_ID_LAN + if BOT_PROFILE == "lan" + else SHEET_ID_DEV or os.getenv("SHEET_ID") +) GOOGLE_CREDS_PATH = os.getenv("GOOGLE_CREDS_PATH", "credentials.json") _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 +GUILD_ID_LAN = _env_int("GUILD_ID_LAN", 0) +GUILD_ID = { + "dev": GUILD_ID_DEV, + "economy": GUILD_ID_ECONOMY, + "lan": GUILD_ID_LAN, +}[BOT_PROFILE] _LEGACY_BIRTHDAY_CHANNEL_ID = _env_int("BIRTHDAY_CHANNEL_ID", 0) BIRTHDAY_CHANNEL_ID_DEV = _env_int("BIRTHDAY_CHANNEL_ID_DEV", _LEGACY_BIRTHDAY_CHANNEL_ID) @@ -55,6 +69,21 @@ 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 +PB_ECONOMY_COLLECTION_LAN = ( + os.getenv("PB_ECONOMY_COLLECTION_LAN", "").strip() + or (_LEGACY_PB_COLLECTION if _LEGACY_PB_COLLECTION else "economy_users_lan") ) +PB_ECONOMY_COLLECTION = { + "dev": PB_ECONOMY_COLLECTION_DEV, + "economy": PB_ECONOMY_COLLECTION_ECONOMY, + "lan": PB_ECONOMY_COLLECTION_LAN, +}[BOT_PROFILE] + +PB_FIENTA_COLLECTION_LAN = ( + os.getenv("PB_FIENTA_COLLECTION_LAN", "").strip() + or "fienta_registrations_lan" +) + +FIENTA_WEBHOOK_SECRET = os.getenv("FIENTA_WEBHOOK_SECRET", "").strip() +FIENTA_WEBHOOK_PORT = _env_int("FIENTA_WEBHOOK_PORT", 8090) +FIENTA_ADMIN_ALERT_CHANNEL_ID = _env_int("FIENTA_ADMIN_ALERT_CHANNEL_ID", 0) diff --git a/core/lan_fienta.py b/core/lan_fienta.py new file mode 100644 index 0000000..bebf7d4 --- /dev/null +++ b/core/lan_fienta.py @@ -0,0 +1,868 @@ +"""Fienta registration sync for the LAN bot profile. + +The module keeps Fienta data in PocketBase, assigns Discord roles, and mirrors +public tournament teams into the LAN live registration sheet. +""" + +from __future__ import annotations + +import asyncio +import datetime as dt +import logging +import re +import unicodedata +from dataclasses import dataclass, field +from typing import Any + +import discord +import gspread +from google.oauth2.service_account import Credentials + +import config +from . import pb_client + +log = logging.getLogger("tipilan.fienta") + +SCOPES = [ + "https://www.googleapis.com/auth/spreadsheets", + "https://www.googleapis.com/auth/drive", +] + +CS2_GENERAL_ROLE_ID = 1498736834656604251 +LOL_GENERAL_ROLE_ID = 1498736949706490017 +LANGUAGE_GENERAL_ROLE_ID = 1416417344984715366 +CS2_CAPTAIN_ROLE_ID = 1498738332316860426 +CS2_MANAGER_ROLE_ID = 1498738500558655679 + +EXISTING_LANGUAGE_ROLE_IDS = { + "EE": 1425781026482950245, + "LV": 1425781129528606740, + "FI": 1425781429618348073, +} + +CONFIRMED_TEXT = "Kinnitatud" +PENDING_TEXT = "Kinnitamisel" + +CANCELLED_STATUSES = {"CANCELLED", "REFUNDED", "EXPIRED", "VOIDED"} +BLOCKED_COUNTRY_CODES = {"BY", "RU"} +BLOCKED_COUNTRY_NAMES = { + "belarus", + "russia", + "russian federation", + "valgevene", + "venemaa", + "vene föderatsioon", + "vene foderatsioon", +} + +TICKET_TYPES: dict[str, dict[str, Any]] = { + "595507": { + "game": "cs2", + "kind": "participant", + "sheet_public": True, + "main": True, + }, + "595509": { + "game": "cs2", + "kind": "reserve", + "sheet_public": False, + "main": False, + }, + "595510": { + "game": "cs2", + "kind": "manager", + "sheet_public": False, + "main": False, + }, + "595912": { + "game": "lol", + "kind": "participant", + "sheet_public": True, + "main": True, + }, +} + +SHEET_CONFIG = { + "cs2": {"worksheet": "CS2", "start_row": 6, "end_row": 37, "cols": 6}, + "lol": {"worksheet": "LoL", "start_row": 6, "end_row": 17, "cols": 5}, +} + +COUNTRY_CODE_BY_NAME = { + "afghanistan": "AF", + "albaania": "AL", + "albania": "AL", + "andorra": "AD", + "armeenia": "AM", + "armenia": "AM", + "austria": "AT", + "austria vabariik": "AT", + "azerbaijan": "AZ", + "aserbaidžaan": "AZ", + "belgia": "BE", + "belgium": "BE", + "bosnia ja hertsegoviina": "BA", + "bosnia and herzegovina": "BA", + "bulgaaria": "BG", + "bulgaria": "BG", + "canada": "CA", + "kanada": "CA", + "croatia": "HR", + "eesti": "EE", + "estonia": "EE", + "est": "EE", + "denmark": "DK", + "taani": "DK", + "finland": "FI", + "soome": "FI", + "france": "FR", + "prantsusmaa": "FR", + "georgia": "GE", + "gruusia": "GE", + "germany": "DE", + "saksamaa": "DE", + "greece": "GR", + "kreeka": "GR", + "hungary": "HU", + "ungari": "HU", + "iceland": "IS", + "island": "IS", + "ireland": "IE", + "iirimaa": "IE", + "italy": "IT", + "itaalia": "IT", + "japan": "JP", + "jaapan": "JP", + "kazakhstan": "KZ", + "kasahstan": "KZ", + "latvia": "LV", + "läti": "LV", + "lati": "LV", + "liechtenstein": "LI", + "lithuania": "LT", + "leedu": "LT", + "luxembourg": "LU", + "luksemburg": "LU", + "malta": "MT", + "moldova": "MD", + "montenegro": "ME", + "netherlands": "NL", + "holland": "NL", + "madalmaad": "NL", + "norway": "NO", + "norra": "NO", + "poland": "PL", + "poola": "PL", + "portugal": "PT", + "romania": "RO", + "rumeenia": "RO", + "serbia": "RS", + "slovakia": "SK", + "slovakkia": "SK", + "slovenia": "SI", + "sloveenia": "SI", + "spain": "ES", + "hispaania": "ES", + "sweden": "SE", + "rootsi": "SE", + "switzerland": "CH", + "šveits": "CH", + "sveits": "CH", + "turkey": "TR", + "türgi": "TR", + "ukraine": "UA", + "ukraina": "UA", + "united kingdom": "GB", + "suurbritannia": "GB", + "great britain": "GB", + "united states": "US", + "usa": "US", + "ameerika ühendriigid": "US", + "ameerika uhendriigid": "US", + "belarus": "BY", + "valgevene": "BY", + "russia": "RU", + "russian federation": "RU", + "venemaa": "RU", +} + +COUNTRY_ROLE_COLOURS = { + "EE": 0x0072CE, + "LV": 0x9E3039, + "FI": 0x003580, + "LT": 0xFDB913, + "SE": 0x006AA7, + "DE": 0xDD0000, + "PL": 0xDC143C, + "UA": 0x0057B7, + "GB": 0x012169, + "US": 0x3C3B6E, +} + +_client: gspread.Client | None = None +_spreadsheet: gspread.Spreadsheet | None = None +_sync_lock = asyncio.Lock() + + +@dataclass +class SyncSummary: + saved: int = 0 + created: int = 0 + updated: int = 0 + roles_synced: int = 0 + unmatched: int = 0 + sheet_rows: int = 0 + alerts: list[str] = field(default_factory=list) + + def short(self) -> str: + return ( + f"saved={self.saved}, created={self.created}, updated={self.updated}, " + f"roles={self.roles_synced}, unmatched={self.unmatched}, sheet_rows={self.sheet_rows}, " + f"alerts={len(self.alerts)}" + ) + + +def _text_field(name: str, required: bool = False) -> dict: + return { + "name": name, + "type": "text", + "required": required, + "options": {"min": None, "max": None, "pattern": ""}, + } + + +def _bool_field(name: str) -> dict: + return {"name": name, "type": "bool", "required": False} + + +def fienta_collection_payload() -> dict: + fields = [ + _text_field("registration_key", required=True), + _text_field("order_id"), + _text_field("ticket_code"), + _text_field("order_status"), + _text_field("order_url"), + _text_field("payment_time"), + _text_field("game"), + _text_field("kind"), + _text_field("ticket_type_id"), + _text_field("ticket_title"), + _text_field("ticket_group_title"), + _text_field("team_name"), + _text_field("discord_username"), + _text_field("nickname"), + _text_field("country"), + _text_field("country_code"), + _text_field("riot_id"), + _text_field("steam64_id"), + _text_field("vrs_ranking"), + _bool_field("is_main"), + _bool_field("is_reserve"), + _bool_field("is_manager"), + _bool_field("is_captain"), + _bool_field("sheet_public"), + _bool_field("blocked_country"), + _bool_field("active"), + _bool_field("roles_synced"), + _text_field("last_sync_error"), + _text_field("updated_at"), + ] + return { + "name": config.PB_FIENTA_COLLECTION_LAN, + "type": "base", + "fields": fields, + "listRule": None, + "viewRule": None, + "createRule": None, + "updateRule": None, + "deleteRule": None, + } + + +async def ensure_storage() -> bool: + """Create the LAN Fienta collection when it does not exist.""" + return await pb_client.ensure_collection( + config.PB_FIENTA_COLLECTION_LAN, + fienta_collection_payload(), + ) + + +def _strip_accents(value: str) -> str: + normalized = unicodedata.normalize("NFKD", value) + return "".join(ch for ch in normalized if not unicodedata.combining(ch)) + + +def _norm(value: Any) -> str: + return re.sub(r"\s+", " ", str(value or "")).strip() + + +def _norm_key(value: Any) -> str: + return _strip_accents(_norm(value)).casefold() + + +def _discord_key(value: Any) -> str: + return _norm(value).lstrip("@").casefold() + + +def _country_code(country: str) -> str: + raw = _norm(country) + if not raw: + return "" + key = _norm_key(raw) + if key in COUNTRY_CODE_BY_NAME: + return COUNTRY_CODE_BY_NAME[key] + for name, code in COUNTRY_CODE_BY_NAME.items(): + if _norm_key(name) == key: + return code + upper = raw.upper() + if re.fullmatch(r"[A-Z]{2}", upper): + return upper + letters = re.sub(r"[^A-Z]", "", _strip_accents(upper)) + return (letters[:2] or "XX").upper() + + +def _is_blocked_country(country: str, code: str) -> bool: + country_key = _norm_key(country) + return code in BLOCKED_COUNTRY_CODES or any( + _norm_key(name) == country_key for name in BLOCKED_COUNTRY_NAMES + ) + + +def _role_safe_name(name: str) -> str: + cleaned = re.sub(r"\s+", " ", _norm(name)).strip("@# ") + return cleaned[:90] or "Unknown" + + +def _team_role_name(game: str, team_name: str) -> str: + prefix = "CS2" if game == "cs2" else "LoL" + return f"[{prefix}] {_role_safe_name(team_name)}"[:100] + + +def _language_role_name(code: str) -> str: + return f"[{code.upper()}]" + + +def _role_colour_for_country(code: str) -> discord.Color: + if code in COUNTRY_ROLE_COLOURS: + return discord.Color(COUNTRY_ROLE_COLOURS[code]) + seed = sum(ord(ch) for ch in code) + hue = seed % 6 + colours = [0x5865F2, 0x57F287, 0xFEE75C, 0xEB459E, 0xED4245, 0x00A8FC] + return discord.Color(colours[hue]) + + +def _now_iso() -> str: + return dt.datetime.now(dt.timezone.utc).isoformat() + + +def _ticket_rows(payload: dict[str, Any]) -> list[dict[str, Any]]: + order = payload.get("order") or {} + order_id = _norm(order.get("id")) + status = _norm(order.get("status")).upper() + payment = order.get("payment") or {} + payment_time = _norm(payment.get("time")) + order_url = _norm(order.get("order_url")) + results: list[dict[str, Any]] = [] + + for ticket in order.get("tickets") or []: + ticket_code = _norm(ticket.get("code")) + for idx, row in enumerate(ticket.get("rows") or []): + ticket_type = row.get("ticket_type") or {} + ticket_type_id = _norm(ticket_type.get("id")) + mapping = TICKET_TYPES.get(ticket_type_id) + if not mapping: + continue + + attendee = row.get("attendee") or {} + country = _norm(attendee.get("country")) + code = _country_code(country) + kind = str(mapping["kind"]) + nickname = ( + _norm(attendee.get("nickname_134815")) + or _norm(attendee.get("nickname_134816")) + or _norm(attendee.get("full_name")) + ) + captain_raw = _norm(attendee.get("tiimi_kapten_134872")).casefold() + registration_key = f"{order_id}:{ticket_code}:{idx}" + results.append( + { + "registration_key": registration_key, + "order_id": order_id, + "ticket_code": ticket_code, + "order_status": status, + "order_url": order_url, + "payment_time": payment_time, + "game": mapping["game"], + "kind": kind, + "ticket_type_id": ticket_type_id, + "ticket_title": _norm(ticket_type.get("title")), + "ticket_group_title": _norm((ticket_type.get("ticket_type_group") or {}).get("title")), + "team_name": _norm(attendee.get("team_name_134821")), + "discord_username": _norm(attendee.get("discord_username_134871")), + "nickname": nickname, + "country": country, + "country_code": code, + "riot_id": _norm(attendee.get("riot_id_134870")), + "steam64_id": _norm(attendee.get("steam64_id_134819")), + "vrs_ranking": _norm(attendee.get("team_vrs_ranking_134825")), + "is_main": bool(mapping["main"]), + "is_reserve": kind == "reserve", + "is_manager": kind == "manager", + "is_captain": captain_raw in {"jah", "yes", "true", "1"}, + "sheet_public": bool(mapping["sheet_public"]), + "blocked_country": _is_blocked_country(country, code), + "active": status == "COMPLETED", + "roles_synced": False, + "last_sync_error": "", + "updated_at": _now_iso(), + } + ) + return results + + +async def process_payload(bot: discord.Client, payload: dict[str, Any]) -> SyncSummary: + """Store a Fienta webhook payload and resync LAN roles/sheets.""" + async with _sync_lock: + summary = SyncSummary() + await ensure_storage() + rows = _ticket_rows(payload) + if not rows: + summary.alerts.append("Fienta webhook did not contain any known tournament ticket rows.") + await _send_alerts(bot, summary.alerts) + return summary + + for row in rows: + _, created = await pb_client.upsert_record_by_field( + config.PB_FIENTA_COLLECTION_LAN, + "registration_key", + row["registration_key"], + row, + ) + summary.saved += 1 + if created: + summary.created += 1 + else: + summary.updated += 1 + + resync = await resync_all(bot, send_alerts=False) + summary.roles_synced += resync.roles_synced + summary.unmatched += resync.unmatched + summary.sheet_rows += resync.sheet_rows + summary.alerts.extend(resync.alerts) + await _send_alerts(bot, summary.alerts) + return summary + + +async def resync_all(bot: discord.Client, send_alerts: bool = True) -> SyncSummary: + """Re-apply all stored Fienta registrations to Discord and Sheets.""" + summary = SyncSummary() + await ensure_storage() + records = await _all_registration_records() + await _sync_roles(bot, records, summary) + sheet_rows, sheet_alerts = await asyncio.to_thread(_sync_public_sheets, records) + summary.sheet_rows += sheet_rows + summary.alerts.extend(sheet_alerts) + if send_alerts: + await _send_alerts(bot, summary.alerts) + return summary + + +async def sync_member_join(bot: discord.Client, member: discord.Member) -> SyncSummary: + """Apply any stored registrations that match a newly joined member.""" + if member.guild.id != config.GUILD_ID: + return SyncSummary() + summary = SyncSummary() + await ensure_storage() + target = _discord_key(member.name) + records = [ + record + for record in await _all_registration_records() + if _discord_key(record.get("discord_username")) == target + ] + if not records: + return summary + await _sync_roles(bot, records, summary, preloaded_member=member) + await _send_alerts(bot, summary.alerts) + return summary + + +async def count_records() -> int: + await ensure_storage() + return await pb_client.count_records_in(config.PB_FIENTA_COLLECTION_LAN) + + +async def _all_registration_records() -> list[dict[str, Any]]: + return await pb_client.list_all_records_in(config.PB_FIENTA_COLLECTION_LAN) + + +async def _sync_roles( + bot: discord.Client, + records: list[dict[str, Any]], + summary: SyncSummary, + preloaded_member: discord.Member | None = None, +) -> None: + guild = bot.get_guild(config.GUILD_ID) + if guild is None: + summary.alerts.append(f"LAN guild {config.GUILD_ID} is not available to the bot.") + return + if preloaded_member is None: + await _ensure_member_cache(guild) + + captain_counts: dict[tuple[str, str], int] = {} + for record in records: + if ( + record.get("game") == "cs2" + and record.get("is_main") + and record.get("is_captain") + and record.get("active") + ): + key = ("cs2", _norm_key(record.get("team_name"))) + captain_counts[key] = captain_counts.get(key, 0) + 1 + for (_, team_key), count in captain_counts.items(): + if count > 1: + team = next( + (_norm(r.get("team_name")) for r in records if _norm_key(r.get("team_name")) == team_key), + team_key, + ) + summary.alerts.append(f"Multiple CS2 captains marked for team `{team}` ({count}).") + + for record in records: + error = await _sync_record_roles(guild, record, summary, preloaded_member) + try: + await pb_client.update_record_in( + config.PB_FIENTA_COLLECTION_LAN, + record["id"], + { + "roles_synced": not bool(error), + "last_sync_error": error, + "updated_at": _now_iso(), + }, + ) + except Exception as exc: + summary.alerts.append( + f"Could not update sync state for `{record.get('registration_key')}`: {exc}" + ) + + +async def _sync_record_roles( + guild: discord.Guild, + record: dict[str, Any], + summary: SyncSummary, + preloaded_member: discord.Member | None = None, +) -> str: + team_name = _norm(record.get("team_name")) + username = _norm(record.get("discord_username")) + game = _norm(record.get("game")) + status = _norm(record.get("order_status")).upper() + country = _norm(record.get("country")) + country_code = _norm(record.get("country_code")) + + if status in CANCELLED_STATUSES: + summary.alerts.append( + f"Registration `{record.get('registration_key')}` is {status}; no automatic role removal was done." + ) + return "inactive order" + if not record.get("active"): + summary.alerts.append( + f"Registration `{record.get('registration_key')}` is `{status or 'UNKNOWN'}`; roles not assigned yet." + ) + return "order not completed" + if record.get("blocked_country"): + summary.alerts.append( + f"Blocked country registration skipped: `{username}` / `{team_name}` / `{country}`." + ) + return "blocked country" + if not username: + summary.unmatched += 1 + summary.alerts.append(f"Registration `{record.get('registration_key')}` has no Discord username.") + return "missing Discord username" + if not team_name: + summary.alerts.append(f"Registration `{record.get('registration_key')}` has no team name.") + return "missing team name" + + member = preloaded_member or _find_member_by_username(guild, username) + if member is None: + summary.unmatched += 1 + summary.alerts.append(f"No Discord member found for `{username}` ({game.upper()} `{team_name}`).") + return "Discord member not found" + + roles: list[discord.Role] = [] + general_role_id = CS2_GENERAL_ROLE_ID if game == "cs2" else LOL_GENERAL_ROLE_ID + general_role = guild.get_role(general_role_id) + if general_role is None: + return f"general role {general_role_id} not found" + roles.append(general_role) + + team_role = await _get_or_create_role( + guild, + _team_role_name(game, team_name), + anchor=general_role, + colour=general_role.color if general_role.color.value else discord.Color.default(), + ) + roles.append(team_role) + + language_general = guild.get_role(LANGUAGE_GENERAL_ROLE_ID) + if language_general: + roles.append(language_general) + if country_code: + country_role = await _get_or_create_country_role(guild, country_code, language_general) + roles.append(country_role) + else: + summary.alerts.append(f"Missing country for `{username}` ({game.upper()} `{team_name}`).") + else: + summary.alerts.append(f"Language general role {LANGUAGE_GENERAL_ROLE_ID} not found.") + + if game == "cs2" and record.get("is_main") and record.get("is_captain"): + captain_role = guild.get_role(CS2_CAPTAIN_ROLE_ID) + if captain_role: + roles.append(captain_role) + else: + summary.alerts.append(f"CS2 Captain role {CS2_CAPTAIN_ROLE_ID} not found.") + if game == "cs2" and record.get("is_manager"): + manager_role = guild.get_role(CS2_MANAGER_ROLE_ID) + if manager_role: + roles.append(manager_role) + else: + summary.alerts.append(f"CS2 Manager role {CS2_MANAGER_ROLE_ID} not found.") + + missing = [role for role in _unique_roles(roles) if role not in member.roles] + if missing: + try: + await member.add_roles(*missing, reason="Fienta LAN registration sync") + except discord.Forbidden: + return "bot lacks permission to add roles" + except discord.HTTPException as exc: + return f"Discord role add failed: {exc}" + summary.roles_synced += 1 + return "" + + +async def _ensure_member_cache(guild: discord.Guild) -> None: + try: + if not guild.chunked: + await guild.chunk(cache=True) + except Exception as exc: + log.warning("Could not chunk guild members for Fienta sync: %s", exc) + + +def _find_member_by_username(guild: discord.Guild, username: str) -> discord.Member | None: + target = _discord_key(username) + if not target: + return None + for member in guild.members: + candidates = [member.name, getattr(member, "global_name", None), member.display_name] + if any(_discord_key(candidate) == target for candidate in candidates if candidate): + return member + return None + + +def _unique_roles(roles: list[discord.Role]) -> list[discord.Role]: + seen: set[int] = set() + result: list[discord.Role] = [] + for role in roles: + if role.id not in seen: + seen.add(role.id) + result.append(role) + return result + + +async def _get_or_create_country_role( + guild: discord.Guild, + country_code: str, + anchor: discord.Role, +) -> discord.Role: + code = country_code.upper() + existing_id = EXISTING_LANGUAGE_ROLE_IDS.get(code) + if existing_id: + role = guild.get_role(existing_id) + if role: + return role + return await _get_or_create_role( + guild, + _language_role_name(code), + anchor=anchor, + colour=_role_colour_for_country(code), + ) + + +async def _get_or_create_role( + guild: discord.Guild, + name: str, + anchor: discord.Role, + colour: discord.Color, +) -> discord.Role: + role = discord.utils.get(guild.roles, name=name) + if role is None: + role = await guild.create_role(name=name, color=colour, reason="Fienta LAN registration sync") + await _move_role_under(guild, role, anchor) + return role + + +async def _move_role_under(guild: discord.Guild, role: discord.Role, anchor: discord.Role) -> None: + if role.position == max(anchor.position - 1, 1): + return + try: + await guild.edit_role_positions(positions={role: max(anchor.position - 1, 1)}) + except discord.Forbidden: + log.warning("No permission to move role %s under %s", role.name, anchor.name) + except discord.HTTPException as exc: + log.warning("Could not move role %s under %s: %s", role.name, anchor.name, exc) + + +def _get_spreadsheet() -> gspread.Spreadsheet: + global _client, _spreadsheet + if _spreadsheet is not None: + return _spreadsheet + creds = Credentials.from_service_account_file(config.GOOGLE_CREDS_PATH, scopes=SCOPES) + _client = gspread.authorize(creds) + _spreadsheet = _client.open_by_key(config.SHEET_ID) + return _spreadsheet + + +def _sync_public_sheets(records: list[dict[str, Any]]) -> tuple[int, list[str]]: + alerts: list[str] = [] + rows_written = 0 + spreadsheet = _get_spreadsheet() + for game in ("cs2", "lol"): + cfg = SHEET_CONFIG[game] + try: + worksheet = spreadsheet.worksheet(cfg["worksheet"]) + except gspread.WorksheetNotFound: + alerts.append(f"Worksheet `{cfg['worksheet']}` not found in LAN live sheet.") + continue + teams = _public_teams(records, game) + rows_written += _write_game_sheet(worksheet, game, teams, alerts) + return rows_written, alerts + + +def _public_teams(records: list[dict[str, Any]], game: str) -> list[dict[str, Any]]: + by_team: dict[str, dict[str, Any]] = {} + for record in records: + status = _norm(record.get("order_status")).upper() + if record.get("game") != game or not record.get("sheet_public"): + continue + if record.get("blocked_country") or status in CANCELLED_STATUSES: + continue + team_name = _norm(record.get("team_name")) + if not team_name: + continue + key = _norm_key(team_name) + team = by_team.setdefault( + key, + { + "team_name": team_name, + "lineup": [], + "vrs": "", + "payment_time": _norm(record.get("payment_time")), + "confirmed": True, + }, + ) + team["lineup"].append( + { + "nickname": _norm(record.get("nickname")), + "country": _norm(record.get("country")), + "country_code": _norm(record.get("country_code")), + } + ) + if not team["vrs"] and record.get("vrs_ranking"): + team["vrs"] = _norm(record.get("vrs_ranking")) + if _norm(record.get("payment_time")) < team["payment_time"]: + team["payment_time"] = _norm(record.get("payment_time")) + if status != "COMPLETED": + team["confirmed"] = False + return sorted(by_team.values(), key=lambda t: (t["payment_time"], _norm_key(t["team_name"]))) + + +def _write_game_sheet( + worksheet: gspread.Worksheet, + game: str, + teams: list[dict[str, Any]], + alerts: list[str], +) -> int: + cfg = SHEET_CONFIG[game] + start_row = int(cfg["start_row"]) + end_row = int(cfg["end_row"]) + capacity = end_row - start_row + 1 + existing = worksheet.get(f"B{start_row}:B{end_row}") + by_name: dict[str, int] = {} + empty_rows: list[int] = [] + for offset in range(capacity): + row_num = start_row + offset + value = "" + if offset < len(existing) and existing[offset]: + value = _norm(existing[offset][0]) + if value: + by_name[_norm_key(value)] = row_num + else: + empty_rows.append(row_num) + + rows_written = 0 + for team in teams: + key = _norm_key(team["team_name"]) + row_num = by_name.get(key) + if row_num is None: + if not empty_rows: + alerts.append(f"{game.upper()} live sheet is full; `{team['team_name']}` was not added.") + continue + row_num = empty_rows.pop(0) + by_name[key] = row_num + + no = row_num - start_row + 1 + lineup = "\n".join(_lineup_entry(player) for player in team["lineup"]) + timestamp = _format_sheet_time(team["payment_time"]) + status = CONFIRMED_TEXT if team["confirmed"] else PENDING_TEXT + if game == "cs2": + values = [[no, team["team_name"], lineup, team["vrs"], timestamp, status]] + worksheet.update(values, f"A{row_num}:F{row_num}", value_input_option="USER_ENTERED") + else: + values = [[no, team["team_name"], lineup, timestamp, status]] + worksheet.update(values, f"A{row_num}:E{row_num}", value_input_option="USER_ENTERED") + rows_written += 1 + return rows_written + + +def _lineup_entry(player: dict[str, str]) -> str: + nickname = player.get("nickname") or "?" + country = player.get("country") or player.get("country_code") or "?" + return f"{nickname}, {country}" + + +def _format_sheet_time(raw: str) -> str: + if not raw: + return "" + try: + parsed = dt.datetime.fromisoformat(raw) + except ValueError: + return raw + return parsed.strftime("%d.%m.%Y %H:%M") + + +async def _send_alerts(bot: discord.Client, alerts: list[str]) -> None: + if not alerts or not config.FIENTA_ADMIN_ALERT_CHANNEL_ID: + return + try: + channel = bot.get_channel(config.FIENTA_ADMIN_ALERT_CHANNEL_ID) + if channel is None: + channel = await bot.fetch_channel(config.FIENTA_ADMIN_ALERT_CHANNEL_ID) + except Exception as exc: + log.warning("Could not fetch Fienta alert channel: %s", exc) + return + if not hasattr(channel, "send"): + return + + header = "**Fienta LAN sync alerts**" + chunks: list[str] = [] + current = header + for alert in alerts: + line = f"\n- {alert}" + if len(current) + len(line) > 1900: + chunks.append(current) + current = header + line + else: + current += line + chunks.append(current) + for chunk in chunks: + try: + await channel.send(chunk) + except Exception as exc: + log.warning("Could not send Fienta alert: %s", exc) + break diff --git a/core/pb_client.py b/core/pb_client.py index abce617..59634b8 100644 --- a/core/pb_client.py +++ b/core/pb_client.py @@ -7,7 +7,7 @@ Environment variables (set in .env): PB_URL Base URL of PocketBase (default: http://127.0.0.1:8090) PB_ADMIN_EMAIL PocketBase admin e-mail PB_ADMIN_PASSWORD PocketBase admin password - PB_ECONOMY_COLLECTION_DEV / PB_ECONOMY_COLLECTION_ECONOMY + PB_ECONOMY_COLLECTION_DEV / PB_ECONOMY_COLLECTION_ECONOMY / PB_ECONOMY_COLLECTION_LAN """ from __future__ import annotations @@ -75,16 +75,28 @@ async def _hdrs() -> dict[str, str]: return {"Authorization": await _ensure_auth()} +def _escape_filter_value(value: str) -> str: + return value.replace("\\", "\\\\").replace('"', '\\"') + + # --------------------------------------------------------------------------- # CRUD helpers # --------------------------------------------------------------------------- async def get_record(user_id: str) -> dict[str, Any] | None: """Fetch one economy record by Discord user_id. Returns None if not found.""" + return await get_first_record( + ECONOMY_COLLECTION, + f'user_id="{_escape_filter_value(user_id)}"', + ) + + +async def get_first_record(collection: str, filter_expr: str) -> dict[str, Any] | None: + """Fetch one record from any collection by a PocketBase filter expression.""" session = _get_session() async with session.get( - f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records", - params={"filter": f'user_id="{user_id}"', "perPage": 1}, + f"{PB_URL}/api/collections/{collection}/records", + params={"filter": filter_expr, "perPage": 1}, headers=await _hdrs(), ) as resp: resp.raise_for_status() @@ -93,11 +105,22 @@ async def get_record(user_id: str) -> dict[str, Any] | None: return items[0] if items else None +async def get_record_by_field(collection: str, field: str, value: str) -> dict[str, Any] | None: + """Fetch one record where `field` exactly equals `value`.""" + escaped = _escape_filter_value(value) + return await get_first_record(collection, f'{field}="{escaped}"') + + async def create_record(record: dict[str, Any]) -> dict[str, Any]: """Create a new economy record. Returns the created record (includes PB id).""" + return await create_record_in(ECONOMY_COLLECTION, record) + + +async def create_record_in(collection: str, record: dict[str, Any]) -> dict[str, Any]: + """Create a new record in any collection. Returns the created record.""" session = _get_session() async with session.post( - f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records", + f"{PB_URL}/api/collections/{collection}/records", json=record, headers=await _hdrs(), ) as resp: @@ -109,9 +132,14 @@ async def create_record(record: dict[str, Any]) -> dict[str, Any]: async def update_record(record_id: str, data: dict[str, Any]) -> dict[str, Any]: """PATCH an existing record by its PocketBase record id.""" + return await update_record_in(ECONOMY_COLLECTION, record_id, data) + + +async def update_record_in(collection: str, record_id: str, data: dict[str, Any]) -> dict[str, Any]: + """PATCH an existing record in any collection by its PocketBase record id.""" session = _get_session() async with session.patch( - f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records/{record_id}", + f"{PB_URL}/api/collections/{collection}/records/{record_id}", json=data, headers=await _hdrs(), ) as resp: @@ -121,9 +149,14 @@ async def update_record(record_id: str, data: dict[str, Any]) -> dict[str, Any]: async def count_records() -> int: """Return the total number of records in the collection (single cheap request).""" + return await count_records_in(ECONOMY_COLLECTION) + + +async def count_records_in(collection: str) -> int: + """Return the total number of records in any collection.""" session = _get_session() async with session.get( - f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records", + f"{PB_URL}/api/collections/{collection}/records", params={"perPage": 1, "page": 1}, headers=await _hdrs(), ) as resp: @@ -134,13 +167,18 @@ async def count_records() -> int: async def list_all_records(page_size: int = 500) -> list[dict[str, Any]]: """Fetch every record in the collection, handling PocketBase pagination.""" + return await list_all_records_in(ECONOMY_COLLECTION, page_size=page_size) + + +async def list_all_records_in(collection: str, page_size: int = 500) -> list[dict[str, Any]]: + """Fetch every record in any collection, handling PocketBase pagination.""" results: list[dict[str, Any]] = [] page = 1 session = _get_session() hdrs = await _hdrs() while True: async with session.get( - f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records", + f"{PB_URL}/api/collections/{collection}/records", params={"perPage": page_size, "page": page}, headers=hdrs, ) as resp: @@ -152,3 +190,51 @@ async def list_all_records(page_size: int = 500) -> list[dict[str, Any]]: break page += 1 return results + + +async def upsert_record_by_field( + collection: str, + field: str, + value: str, + data: dict[str, Any], +) -> tuple[dict[str, Any], bool]: + """Create or update a record. Returns (record, created).""" + existing = await get_record_by_field(collection, field, value) + if existing: + return await update_record_in(collection, existing["id"], data), False + return await create_record_in(collection, data), True + + +async def get_collection(collection: str) -> dict[str, Any] | None: + """Fetch collection metadata, returning None if it doesn't exist.""" + session = _get_session() + async with session.get( + f"{PB_URL}/api/collections/{collection}", + headers=await _hdrs(), + ) as resp: + if resp.status == 404: + return None + resp.raise_for_status() + return await resp.json() + + +async def create_collection(payload: dict[str, Any]) -> dict[str, Any]: + """Create a PocketBase collection from a full collection payload.""" + session = _get_session() + async with session.post( + f"{PB_URL}/api/collections", + json=payload, + headers=await _hdrs(), + ) as resp: + if resp.status not in (200, 201): + text = await resp.text() + raise RuntimeError(f"PocketBase collection create failed ({resp.status}): {text}") + return await resp.json() + + +async def ensure_collection(collection: str, payload: dict[str, Any]) -> bool: + """Create `collection` when missing. Returns True if created.""" + if await get_collection(collection): + return False + await create_collection(payload) + return True diff --git a/docs/LAN_FIENTA_SETUP.md b/docs/LAN_FIENTA_SETUP.md new file mode 100644 index 0000000..9c7f8ad --- /dev/null +++ b/docs/LAN_FIENTA_SETUP.md @@ -0,0 +1,79 @@ +# LAN Fienta Setup + +This profile runs the same codebase as a separate Discord bot process: + +```powershell +$env:BOT_PROFILE="lan" +python bot.py +``` + +## Environment + +Required `.env` values: + +```env +BOT_PROFILE=lan +DISCORD_BOT_LAN=... +GUILD_ID_LAN=1301145356750426192 +SHEET_ID_LAN=1cYEI2EmQDMZdVOarbOkVw7IHsnfqtYbWhA-6zC_r-zw +PB_ECONOMY_COLLECTION_LAN=economy_users_lan +PB_FIENTA_COLLECTION_LAN=fienta_registrations_lan +FIENTA_WEBHOOK_SECRET= +FIENTA_WEBHOOK_PORT=8090 +FIENTA_ADMIN_ALERT_CHANNEL_ID=1478302279894302812 +``` + +The LAN bot must be invited to the LAN server and to the dev server that owns +the alert channel. + +## Fienta Webhooks + +Use these URLs in Fienta: + +```text +Ostu sooritamisel: +https://veebikonks.tipilan.ee/fienta/purchase + +Registreerimisvormi täitmisel peale ostu: +https://veebikonks.tipilan.ee/fienta/registration +``` + +Leave `Pileti valideerimisel` empty for now. + +The old secret-token endpoint is still supported for testing: + +```text +https://veebikonks.tipilan.ee/fienta/webhook/ +``` + +## Caddy + +If Caddy is on the same Docker network as the bot service: + +```caddyfile +veebikonks.tipilan.ee { + reverse_proxy /fienta/* bot:8090 +} +``` + +If Caddy runs on the host, make sure `localhost:8090` points to the bot webhook, +not PocketBase. The current compose file publishes PocketBase on host port +`8090`, so host-level Caddy cannot also proxy that same host port to the bot +unless PocketBase is moved or the bot is exposed through a different host route. + +## Ticket Mapping + +- `595507` - CS2 participant, public sheet row, CS2/team/language roles +- `595509` - CS2 reserve, roles only +- `595510` - CS2 manager, CS2/team/language/Manager roles +- `595912` - LoL participant, public sheet row, LoL/team/language roles + +Public sheet rows: + +- `CS2`: rows `6` through `37` +- `LoL`: rows `6` through `17` + +## Admin Command + +Use `/fientasync` in the LAN server to re-apply stored Fienta registrations to +Discord roles and the public sheet. diff --git a/scripts/reset_pb_collections.py b/scripts/reset_pb_collections.py index c4bda08..1a18099 100644 --- a/scripts/reset_pb_collections.py +++ b/scripts/reset_pb_collections.py @@ -1,4 +1,4 @@ -"""Destructively recreate economy PocketBase collections for dev + economy profiles. +"""Destructively recreate TipiBOT PocketBase collections. Usage: python scripts/reset_pb_collections.py --confirm @@ -6,6 +6,8 @@ Usage: This will DELETE and recreate the collections configured by: - PB_ECONOMY_COLLECTION_DEV - PB_ECONOMY_COLLECTION_ECONOMY +- PB_ECONOMY_COLLECTION_LAN +- PB_FIENTA_COLLECTION_LAN """ from __future__ import annotations @@ -114,6 +116,51 @@ def _collection_payload(name: str) -> dict: } +def _fienta_collection_payload(name: str) -> dict: + fields = [ + _text_field("registration_key", required=True), + _text_field("order_id"), + _text_field("ticket_code"), + _text_field("order_status"), + _text_field("order_url"), + _text_field("payment_time"), + _text_field("game"), + _text_field("kind"), + _text_field("ticket_type_id"), + _text_field("ticket_title"), + _text_field("ticket_group_title"), + _text_field("team_name"), + _text_field("discord_username"), + _text_field("nickname"), + _text_field("country"), + _text_field("country_code"), + _text_field("riot_id"), + _text_field("steam64_id"), + _text_field("vrs_ranking"), + _bool_field("is_main"), + _bool_field("is_reserve"), + _bool_field("is_manager"), + _bool_field("is_captain"), + _bool_field("sheet_public"), + _bool_field("blocked_country"), + _bool_field("active"), + _bool_field("roles_synced"), + _text_field("last_sync_error"), + _text_field("updated_at"), + ] + + return { + "name": name, + "type": "base", + "fields": fields, + "listRule": None, + "viewRule": None, + "createRule": None, + "updateRule": None, + "deleteRule": None, + } + + async def _auth_token(session: aiohttp.ClientSession) -> str: async with session.post( f"{PB_URL}/api/collections/_superusers/auth-with-password", @@ -138,8 +185,12 @@ async def _delete_if_exists(session: aiohttp.ClientSession, headers: dict[str, s print(f"[DELETE] {name}") -async def _create_collection(session: aiohttp.ClientSession, headers: dict[str, str], name: str) -> None: - payload = _collection_payload(name) +async def _create_collection( + session: aiohttp.ClientSession, + headers: dict[str, str], + name: str, + payload: dict, +) -> None: async with session.post(f"{PB_URL}/api/collections", json=payload, headers=headers) as resp: if resp.status not in (200, 201): raise RuntimeError(f"Create failed for {name} ({resp.status}): {await resp.text()}") @@ -154,10 +205,17 @@ async def main() -> None: if not args.confirm: raise SystemExit("Refusing to run without --confirm (this operation deletes collections).") - targets = [] - for name in [config.PB_ECONOMY_COLLECTION_DEV, config.PB_ECONOMY_COLLECTION_ECONOMY]: - if name and name not in targets: - targets.append(name) + targets: list[tuple[str, dict]] = [] + for name in [ + config.PB_ECONOMY_COLLECTION_DEV, + config.PB_ECONOMY_COLLECTION_ECONOMY, + config.PB_ECONOMY_COLLECTION_LAN, + ]: + if name and all(existing != name for existing, _ in targets): + targets.append((name, _collection_payload(name))) + fienta_name = config.PB_FIENTA_COLLECTION_LAN + if fienta_name and all(name != fienta_name for name, _ in targets): + targets.append((fienta_name, _fienta_collection_payload(fienta_name))) if not targets: raise SystemExit("No target collections configured.") @@ -167,12 +225,12 @@ async def main() -> None: token = await _auth_token(session) headers = {"Authorization": token} - for name in targets: + for name, payload in targets: await _delete_if_exists(session, headers, name) - await _create_collection(session, headers, name) + await _create_collection(session, headers, name, payload) print("\nDone. Collections recreated:") - for name in targets: + for name, _ in targets: print(f" - {name}") diff --git a/ssssecret.txt b/ssssecret.txt new file mode 100644 index 0000000..0c61299 --- /dev/null +++ b/ssssecret.txt @@ -0,0 +1,43 @@ +# Runtime profile +BOT_PROFILE=lan + +# Discord bot tokens +DISCORD_TOKEN_DEV=MTQ4MjM2NDcxNzI5MTkzMzc2Ng.G8SmBo._5u6z-Tr13DFpd7n1gI2GfjqorYsvV3S-sOnFA +DISCORD_TOKEN_ECONOMY=MTQ5MDAzNDM5OTU4Mjg4Mzg3MA.GmN2OX.AFxiZcSPAtoO00ARcT8eXV8JvH8vRysvOM9KPU +DISCORD_BOT_LAN=MTQ5MDAzNDM5OTU4Mjg4Mzg3MA.GL25hE.7Fd59Jw52MxxHnfRZtyW33-xSeUsER3teOvHqE +DISCORD_TOKEN= + +# Google Sheets +SHEET_ID=1TyW075sOxefQYbeowNV7AWO8lHv2pe4nT6CcsdvFd_E +SHEET_ID_DEV=1TyW075sOxefQYbeowNV7AWO8lHv2pe4nT6CcsdvFd_E +SHEET_ID_LAN=1cYEI2EmQDMZdVOarbOkVw7IHsnfqtYbWhA-6zC_r-zw +GOOGLE_CREDS_PATH=credentials.json + +# Discord guilds +GUILD_ID_DEV=1478302278086819946 +GUILD_ID_ECONOMY=1301145356750426192 +GUILD_ID_LAN=1301145356750426192 +GUILD_ID= + +# Birthday system +BIRTHDAY_CHANNEL_ID_DEV=1482398641699291357 +BIRTHDAY_CHANNEL_ID_ECONOMY= +BIRTHDAY_CHANNEL_ID= +BIRTHDAY_WINDOW_DAYS=7 + +# PocketBase +PB_URL=http://127.0.0.1:8090 +PB_ADMIN_EMAIL=tipilaninfo@gmail.com +PB_ADMIN_PASSWORD=salakala + +# PocketBase collections +PB_ECONOMY_COLLECTION_DEV=economy_users_dev +PB_ECONOMY_COLLECTION_ECONOMY=economy_users_prod +PB_ECONOMY_COLLECTION_LAN=economy_users_lan +PB_FIENTA_COLLECTION_LAN=fienta_registrations_lan +PB_ECONOMY_COLLECTION= + +# Fienta LAN registration sync +FIENTA_WEBHOOK_SECRET=NC6A4BsaPkPmsT3dayph_p7lpP3-ExWpFFkZSKbljdk +FIENTA_WEBHOOK_PORT=8090 +FIENTA_ADMIN_ALERT_CHANNEL_ID=1478302279894302812 diff --git a/strings.py b/strings.py index cb5cfd5..4946430 100644 --- a/strings.py +++ b/strings.py @@ -171,6 +171,7 @@ CMD: dict[str, str] = { "fish": "Mine kalastama (interaktiivne mäng, 2min ooteaeg)", "fishbook": "Vaata oma kalakogu ja kogutud kalaliike", "fishsell": "Müü kalu oma inventarist", + "fientasync": "[Admin] Sünkroniseeri LAN Fienta registreeringud uuesti", } # --------------------------------------------------------------------------- @@ -306,6 +307,7 @@ HELP_CATEGORIES: dict[str, dict] = { ("/channels", "Näita lubatud kanalite nimekirja"), ("/adminseason [top_n]", "Lõpeta võistlus, teavita võitjaid ja lähtesta EXP"), ("/economysetup", "Loo ja sea korda majandussüsteemi rollid (ECONOMY + taseme rollid) boti rolli alla"), + ("/fientasync", "Sünkroniseeri LAN Fienta registreeringute rollid ja avalik tabel uuesti"), ], }, }