1
0
forked from sass/tipibot

3 Commits

Author SHA1 Message Date
4abc367faf Fixed 2026-04-29 19:57:43 +00:00
AlacrisDevs
691f160a09 Merge branch 'master' of https://git.lapikud.ee/renkar/tipibot into fienta 2026-04-29 22:39:43 +03:00
AlacrisDevs
3c2b4342a2 Added Fienta integration 2026-04-29 22:38:47 +03:00
16 changed files with 1422 additions and 294 deletions

14
.dockerignore Normal file
View File

@@ -0,0 +1,14 @@
.git
.gitignore
.env
.env.*
*.pyc
__pycache__
data/
logs/
*.log
*.md
.dockerignore
Dockerfile
compose.yaml
credentials.json

View File

@@ -2,12 +2,15 @@
# Profile-specific Discord bot tokens (from https://discord.com/developers/applications) # Profile-specific Discord bot tokens (from https://discord.com/developers/applications)
DISCORD_TOKEN_DEV=your-dev-bot-token-here DISCORD_TOKEN_DEV=your-dev-bot-token-here
DISCORD_TOKEN_ECONOMY=your-economy-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) # Legacy fallback token (optional, backward compatibility)
DISCORD_TOKEN= DISCORD_TOKEN=
# Google Sheets spreadsheet ID (the long string in the sheet URL) # Google Sheets spreadsheet ID (the long string in the sheet URL)
SHEET_ID=your-google-sheet-id-here SHEET_ID=your-google-sheet-id-here
SHEET_ID_DEV=
SHEET_ID_LAN=1cYEI2EmQDMZdVOarbOkVw7IHsnfqtYbWhA-6zC_r-zw
# Path to Google service account credentials JSON # Path to Google service account credentials JSON
GOOGLE_CREDS_PATH=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 # Profile-specific guild (server) IDs - right-click your server with dev mode on
GUILD_ID_DEV=your-dev-guild-id-here GUILD_ID_DEV=your-dev-guild-id-here
GUILD_ID_ECONOMY=your-economy-guild-id-here GUILD_ID_ECONOMY=your-economy-guild-id-here
GUILD_ID_LAN=1301145356750426192
# Legacy fallback guild ID (optional, backward compatibility) # Legacy fallback guild ID (optional, backward compatibility)
GUILD_ID= GUILD_ID=
@@ -39,6 +43,16 @@ PB_ADMIN_PASSWORD=your-pb-admin-password
# Profile-specific PocketBase collections # Profile-specific PocketBase collections
PB_ECONOMY_COLLECTION_DEV=economy_users_dev PB_ECONOMY_COLLECTION_DEV=economy_users_dev
PB_ECONOMY_COLLECTION_ECONOMY=economy_users_prod 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) # Legacy fallback collection name (optional, backward compatibility)
PB_ECONOMY_COLLECTION= 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

15
Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM python:3.13-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Create data and logs directories
RUN mkdir -p data logs
CMD ["python", "bot.py"]

View File

@@ -88,14 +88,18 @@ cp .env.example .env
| Variable | Description | | 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_DEV` | Dev bot token from Discord Developer Portal |
| `DISCORD_TOKEN_ECONOMY` | Economy 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) | | `DISCORD_TOKEN` | Legacy fallback token (optional) |
| `SHEET_ID` | ID from the Google Sheet URL | | `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`) | | `GOOGLE_CREDS_PATH` | Path to `credentials.json` (default: `credentials.json`) |
| `GUILD_ID_DEV` | Dev bot guild ID | | `GUILD_ID_DEV` | Dev bot guild ID |
| `GUILD_ID_ECONOMY` | Economy bot guild ID | | `GUILD_ID_ECONOMY` | Economy bot guild ID |
| `GUILD_ID_LAN` | LAN bot guild ID |
| `GUILD_ID` | Legacy fallback guild ID (optional) | | `GUILD_ID` | Legacy fallback guild ID (optional) |
| `BIRTHDAY_CHANNEL_ID_DEV` | Channel for birthday `@here` pings in dev profile | | `BIRTHDAY_CHANNEL_ID_DEV` | Channel for birthday `@here` pings in dev profile |
| `BIRTHDAY_CHANNEL_ID_ECONOMY` | Optional birthday channel in economy 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_ADMIN_PASSWORD` | PocketBase superuser password |
| `PB_ECONOMY_COLLECTION_DEV` | PocketBase collection used by `BOT_PROFILE=dev` | | `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_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) | | `PB_ECONOMY_COLLECTION` | Legacy fallback collection (optional) |
| `FIENTA_WEBHOOK_SECRET` | Optional secret path token for `/fienta/webhook/<secret>` 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 ### 6. Install & Run

96
bot.py
View File

@@ -18,10 +18,11 @@ from discord.ext import tasks
import colorlog import colorlog
import psutil import psutil
from aiohttp import web
import config import config
import strings as S 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 core.member_sync import SyncResult
from commands.dev_member_commands import register_dev_member_commands from commands.dev_member_commands import register_dev_member_commands
from commands.dev_member_runtime import handle_member_join, run_birthday_daily from commands.dev_member_runtime import handle_member_join, run_birthday_daily
@@ -33,9 +34,9 @@ from commands.economy_income_commands import register_economy_income_commands
from commands.economy_prestige_commands import register_prestige_commands from commands.economy_prestige_commands import register_prestige_commands
from commands.economy_profile_commands import register_economy_profile_commands from commands.economy_profile_commands import register_economy_profile_commands
from commands.economy_support_commands import register_economy_support_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_channel_commands import register_ops_channel_commands
from commands.ops_admin_commands import register_ops_admin_commands from commands.ops_admin_commands import register_ops_admin_commands
from commands.info_commands import register_info_commands
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Logging # Logging
@@ -95,6 +96,7 @@ tree = app_commands.CommandTree(bot)
GUILD_OBJ = discord.Object(id=config.GUILD_ID) GUILD_OBJ = discord.Object(id=config.GUILD_ID)
IS_DEV_PROFILE = config.BOT_PROFILE == "dev" IS_DEV_PROFILE = config.BOT_PROFILE == "dev"
IS_LAN_PROFILE = config.BOT_PROFILE == "lan"
TALLINN_TZ = ZoneInfo("Europe/Tallinn") TALLINN_TZ = ZoneInfo("Europe/Tallinn")
_start_time = datetime.datetime.now() _start_time = datetime.datetime.now()
_process = psutil.Process() _process = psutil.Process()
@@ -113,6 +115,8 @@ _RESTART_FILE = _DATA_DIR / "restart_channel.json"
_BOT_CONFIG = _DATA_DIR / "bot_config.json" _BOT_CONFIG = _DATA_DIR / "bot_config.json"
_PAUSED = False # maintenance mode: blocks non-admin commands when True _PAUSED = False # maintenance mode: blocks non-admin commands when True
_DEV_ONLY_COMMANDS: tuple[str, ...] = ("birthdays", "check", "member") _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: def _apply_profile_command_filters() -> None:
@@ -162,6 +166,64 @@ def _member_cache_size() -> int:
return len(sheets.get_cache()) 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 # EXP / Level role helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -390,6 +452,13 @@ async def on_ready():
log.info("Loaded %d member rows from Google Sheets", len(data)) log.info("Loaded %d member rows from Google Sheets", len(data))
except Exception as e: except Exception as e:
log.error("Failed to load sheet on startup: %s", 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 # Sync slash commands to the guild only; wipe any leftover global registrations
tree.copy_global_to(guild=GUILD_OBJ) tree.copy_global_to(guild=GUILD_OBJ)
@@ -408,6 +477,10 @@ async def on_ready():
_rotate_presence.start() _rotate_presence.start()
log.info("Rich presence rotation started") 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 # Re-schedule any reminder tasks lost on restart
await _restore_reminders() await _restore_reminders()
@@ -437,6 +510,11 @@ async def on_resumed():
@bot.event @bot.event
async def on_member_join(member: discord.Member): async def on_member_join(member: discord.Member):
"""When someone joins, look them up in the sheet and sync.""" """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: if not IS_DEV_PROFILE:
return return
await handle_member_join( await handle_member_join(
@@ -461,6 +539,9 @@ if IS_DEV_PROFILE:
mark_announced_today=_mark_announced_today, mark_announced_today=_mark_announced_today,
) )
if IS_LAN_PROFILE:
register_lan_fienta_commands(tree, bot, log)
register_ops_admin_commands( register_ops_admin_commands(
tree, tree,
bot, bot,
@@ -484,8 +565,6 @@ register_ops_channel_commands(
set_allowed_channels=_set_allowed_channels, set_allowed_channels=_set_allowed_channels,
) )
register_info_commands(tree, bot, log)
@tree.command(name="ping", description=S.CMD["ping"]) @tree.command(name="ping", description=S.CMD["ping"])
async def cmd_ping(interaction: discord.Interaction): async def cmd_ping(interaction: discord.Interaction):
@@ -497,6 +576,7 @@ async def cmd_ping(interaction: discord.Interaction):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_HELP_PAGE_SIZE = 10 _HELP_PAGE_SIZE = 10
_DEV_ONLY_HELP_TOKENS: tuple[str, ...] = tuple(f"/{name}" for name in _DEV_ONLY_COMMANDS) _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]]: def _visible_help_fields(category_key: str) -> list[tuple[str, str]]:
@@ -509,6 +589,8 @@ def _visible_help_fields(category_key: str) -> list[tuple[str, str]]:
blob = f"{name}\n{value}".lower() blob = f"{name}\n{value}".lower()
if any(tok in blob for tok in _DEV_ONLY_HELP_TOKENS): if any(tok in blob for tok in _DEV_ONLY_HELP_TOKENS):
continue continue
if not IS_LAN_PROFILE and any(tok in blob for tok in _LAN_ONLY_HELP_TOKENS):
continue
visible.append((name, value)) visible.append((name, value))
return visible return visible
@@ -884,7 +966,11 @@ def _asyncio_exception_handler(loop: asyncio.AbstractEventLoop, context: dict) -
if __name__ == "__main__": if __name__ == "__main__":
if not config.DISCORD_TOKEN: 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( raise SystemExit(
f"{profile_key} pole seadistatud profiilile '{config.BOT_PROFILE}'. " f"{profile_key} pole seadistatud profiilile '{config.BOT_PROFILE}'. "
"Kopeeri .env.example failiks .env ja täida see." "Kopeeri .env.example failiks .env ja täida see."

View File

@@ -1,148 +0,0 @@
from __future__ import annotations
import logging
import re
from pathlib import Path
import discord
from discord import app_commands
import strings as S
_PATCHNOTES_PATH = Path(__file__).resolve().parent.parent / "docs" / "PATCHNOTES.md"
_VERSION_RE = re.compile(r"^##\s+(.+?)\s*$")
_EMBED_DESC_MAX = 4096
_SELECT_OPTIONS_MAX = 25
def _load_versions() -> list[tuple[str, str]]:
try:
text = _PATCHNOTES_PATH.read_text(encoding="utf-8")
except FileNotFoundError:
return []
versions: list[tuple[str, str]] = []
cur_header: str | None = None
cur_body: list[str] = []
for line in text.splitlines():
m = _VERSION_RE.match(line)
if m:
if cur_header is not None:
versions.append((cur_header, "\n".join(cur_body).strip()))
cur_header = m.group(1).strip()
cur_body = []
elif cur_header is not None:
cur_body.append(line)
if cur_header is not None:
versions.append((cur_header, "\n".join(cur_body).strip()))
return versions
def _build_embed(versions: list[tuple[str, str]], idx: int) -> discord.Embed:
header, body = versions[idx]
if len(body) > _EMBED_DESC_MAX:
body = body[: _EMBED_DESC_MAX - 1] + ""
embed = discord.Embed(
title=S.PATCHNOTES_UI["title"].format(version=header),
description=body or S.PATCHNOTES_UI["empty_version"],
color=0x5865F2,
)
embed.set_footer(
text=S.PATCHNOTES_UI["footer"].format(idx=idx + 1, total=len(versions))
)
return embed
def register_info_commands(
tree: app_commands.CommandTree,
bot: discord.Client,
log: logging.Logger,
) -> None:
@tree.command(name="patchnotes", description=S.CMD["patchnotes"])
async def cmd_patchnotes(interaction: discord.Interaction):
versions = _load_versions()
if not versions:
await interaction.response.send_message(
S.PATCHNOTES_UI["empty_file"], ephemeral=True
)
return
invoker_id = interaction.user.id
class PatchNotesView(discord.ui.View):
def __init__(self, idx: int = 0):
super().__init__(timeout=180)
self.idx = idx
self._rebuild()
def _rebuild(self):
self.clear_items()
newer_btn = discord.ui.Button(
label=S.PATCHNOTES_UI["btn_newer"],
style=discord.ButtonStyle.secondary,
disabled=self.idx <= 0,
)
older_btn = discord.ui.Button(
label=S.PATCHNOTES_UI["btn_older"],
style=discord.ButtonStyle.secondary,
disabled=self.idx >= len(versions) - 1,
)
newer_btn.callback = self._make_step_cb(-1)
older_btn.callback = self._make_step_cb(+1)
self.add_item(newer_btn)
self.add_item(older_btn)
opts: list[discord.SelectOption] = []
for i, (hdr, _) in enumerate(versions[:_SELECT_OPTIONS_MAX]):
opts.append(
discord.SelectOption(
label=hdr[:100],
value=str(i),
default=(i == self.idx),
)
)
if len(opts) > 1:
select = discord.ui.Select(
placeholder=S.PATCHNOTES_UI["select_placeholder"],
options=opts,
min_values=1,
max_values=1,
)
select.callback = self._make_select_cb(select)
self.add_item(select)
def _make_step_cb(self, delta: int):
async def _cb(interaction: discord.Interaction):
if interaction.user.id != invoker_id:
await interaction.response.send_message(
S.ERR["not_your_menu"], ephemeral=True
)
return
self.idx = max(0, min(len(versions) - 1, self.idx + delta))
self._rebuild()
await interaction.response.edit_message(
embed=_build_embed(versions, self.idx), view=self
)
return _cb
def _make_select_cb(self, select: discord.ui.Select):
async def _cb(interaction: discord.Interaction):
if interaction.user.id != invoker_id:
await interaction.response.send_message(
S.ERR["not_your_menu"], ephemeral=True
)
return
self.idx = int(select.values[0])
self._rebuild()
await interaction.response.edit_message(
embed=_build_embed(versions, self.idx), view=self
)
return _cb
view = PatchNotesView(0)
await interaction.response.send_message(
embed=_build_embed(versions, 0), view=view, ephemeral=True
)
log.info("/patchnotes by %s (%d versions)", interaction.user, len(versions))

View File

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

35
compose.yaml Normal file
View File

@@ -0,0 +1,35 @@
services:
pocketbase:
image: ghcr.io/muchobien/pocketbase:latest
container_name: tipibot-pocketbase
restart: unless-stopped
volumes:
- pb_data:/pb_data
ports:
- "8090:8090"
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8090/api/health"]
interval: 10s
timeout: 5s
retries: 3
bot:
build: .
container_name: tipibot
restart: unless-stopped
depends_on:
pocketbase:
condition: service_healthy
env_file:
- .env
environment:
- PB_URL=http://pocketbase:8090
expose:
- "8090"
volumes:
- ./data:/app/data
- ./logs:/app/logs
- ./credentials.json:/app/credentials.json:ro
volumes:
pb_data:

View File

@@ -4,8 +4,8 @@ from dotenv import load_dotenv
load_dotenv() load_dotenv()
BOT_PROFILE = os.getenv("BOT_PROFILE", "dev").strip().lower() or "dev" BOT_PROFILE = os.getenv("BOT_PROFILE", "dev").strip().lower() or "dev"
if BOT_PROFILE not in {"dev", "economy"}: if BOT_PROFILE not in {"dev", "economy", "lan"}:
raise SystemExit("BOT_PROFILE must be either 'dev' or 'economy'.") raise SystemExit("BOT_PROFILE must be either 'dev', 'economy', or 'lan'.")
def _env_int(name: str, default: int) -> int: 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", "") _LEGACY_DISCORD_TOKEN = os.getenv("DISCORD_TOKEN", "")
DISCORD_TOKEN_DEV = os.getenv("DISCORD_TOKEN_DEV", "") DISCORD_TOKEN_DEV = os.getenv("DISCORD_TOKEN_DEV", "")
DISCORD_TOKEN_ECONOMY = os.getenv("DISCORD_TOKEN_ECONOMY", "") DISCORD_TOKEN_ECONOMY = os.getenv("DISCORD_TOKEN_ECONOMY", "")
DISCORD_TOKEN = ( DISCORD_BOT_LAN = os.getenv("DISCORD_BOT_LAN", "")
DISCORD_TOKEN_ECONOMY if BOT_PROFILE == "economy" else DISCORD_TOKEN_DEV DISCORD_TOKEN = {
) or _LEGACY_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") GOOGLE_CREDS_PATH = os.getenv("GOOGLE_CREDS_PATH", "credentials.json")
_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)
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) _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_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() os.getenv("PB_ECONOMY_COLLECTION_ECONOMY", "").strip()
or (_LEGACY_PB_COLLECTION if _LEGACY_PB_COLLECTION else "economy_users_prod") or (_LEGACY_PB_COLLECTION if _LEGACY_PB_COLLECTION else "economy_users_prod")
) )
PB_ECONOMY_COLLECTION = ( PB_ECONOMY_COLLECTION_LAN = (
PB_ECONOMY_COLLECTION_ECONOMY if BOT_PROFILE == "economy" else PB_ECONOMY_COLLECTION_DEV 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)

View File

@@ -6,7 +6,6 @@ All public async functions are the single source of truth for mutations.
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
import math import math
import random import random
@@ -16,13 +15,17 @@ from typing import TypedDict
import aiohttp import aiohttp
from . import pb_client from . import pb_client
from .pb_client import DatabaseError
import strings import strings
_txn_log = logging.getLogger("tipiCOIN.txn") _txn_log = logging.getLogger("tipiCOIN.txn")
class DatabaseError(Exception):
"""Raised when PocketBase is unreachable or returns an error."""
pass
def _txn(event: str, **fields) -> None: def _txn(event: str, **fields) -> None:
"""Log a single economy transaction to the transactions logger.""" """Log a single economy transaction to the transactions logger."""
body = " ".join(f"{k}={v}" for k, v in fields.items()) body = " ".join(f"{k}={v}" for k, v in fields.items())
@@ -317,16 +320,16 @@ LEVEL_ROLES: list[tuple[int, str]] = [
def get_level(exp: int) -> int: def get_level(exp: int) -> int:
"""Level = max(1, floor(sqrt(exp/6))). """Level = max(1, floor(sqrt(exp/10))).
Level 5 @ 150 EXP, 10 @ 600, 20 @ 2400, 30 @ 5400.""" Level 5 @ 250 EXP, 10 @ 1000, 20 @ 4000, 30 @ 9000."""
return max(1, int(math.sqrt(max(0, exp) / 6))) return max(1, int(math.sqrt(max(0, exp) / 10)))
def exp_for_level(level: int) -> int: def exp_for_level(level: int) -> int:
"""Minimum cumulative EXP to reach this level. level^2 * 6.""" """Minimum cumulative EXP to reach this level."""
if level <= 1: if level <= 1:
return 0 return 0
return level * level * 6 return level * level * 10
def level_role_name(level: int) -> str: def level_role_name(level: int) -> str:
@@ -686,19 +689,18 @@ async def do_season_reset(top_n: int = 10) -> list[tuple[str, int, int]]:
# Internal write helper # Internal write helper
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def _commit(user_id: int, user: UserData) -> None: async def _commit(user_id: int, user: UserData) -> None:
try:
record_id = user.get("_pb_id") # type: ignore[typeddict-item] record_id = user.get("_pb_id") # type: ignore[typeddict-item]
clean = {k: v for k, v in user.items() if k != "_pb_id"} clean = {k: v for k, v in user.items() if k != "_pb_id"}
clean["user_id"] = str(user_id) clean["user_id"] = str(user_id)
try:
if record_id: if record_id:
await pb_client.update_record(record_id, clean) await pb_client.update_record(record_id, clean)
else: else:
_log.warning("_commit for user %s had no _pb_id; creating new record", user_id) _log.warning("_commit for user %s had no _pb_id; creating new record", user_id)
created = await pb_client.create_record(clean) created = await pb_client.create_record(clean)
user["_pb_id"] = created["id"] # type: ignore[typeddict-unknown-key] user["_pb_id"] = created["id"] # type: ignore[typeddict-unknown-key]
except (aiohttp.ClientError, asyncio.TimeoutError, RuntimeError) as exc: except Exception as exc:
_log.error("_commit failed for user %s: %s", user_id, exc) _log.error("_commit failed for user %s: %s", user_id, exc)
raise DatabaseError(f"Failed to persist user {user_id}: {exc}") from exc
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -949,13 +951,9 @@ async def do_fish_sell(user_id: int, indices: list[int] | None = None) -> dict:
to_sell = inv to_sell = inv
remaining = [] remaining = []
else: else:
sell_idx = { valid_indices = [i if i >= 0 else len(inv) + i for i in indices]
(i if i >= 0 else len(inv) + i) to_sell = [inv[i] for i in sorted(set(valid_indices)) if 0 <= i < len(inv)]
for i in indices keep_idx = set(range(len(inv))) - set(indices)
}
sell_idx = {i for i in sell_idx if 0 <= i < len(inv)}
to_sell = [inv[i] for i in sorted(sell_idx)]
keep_idx = set(range(len(inv))) - sell_idx
remaining = [inv[i] for i in sorted(keep_idx)] remaining = [inv[i] for i in sorted(keep_idx)]
if not to_sell: if not to_sell:

868
core/lan_fienta.py Normal file
View File

@@ -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

View File

@@ -7,7 +7,7 @@ Environment variables (set in .env):
PB_URL Base URL of PocketBase (default: http://127.0.0.1:8090) PB_URL Base URL of PocketBase (default: http://127.0.0.1:8090)
PB_ADMIN_EMAIL PocketBase admin e-mail PB_ADMIN_EMAIL PocketBase admin e-mail
PB_ADMIN_PASSWORD PocketBase admin password 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 from __future__ import annotations
@@ -21,11 +21,6 @@ import aiohttp
import config import config
class DatabaseError(Exception):
"""Raised when PocketBase is unreachable or returns an error."""
pass
_log = logging.getLogger("tipiCOIN.pb") _log = logging.getLogger("tipiCOIN.pb")
PB_URL = config.PB_URL PB_URL = config.PB_URL
@@ -62,20 +57,17 @@ async def _ensure_auth() -> str:
if time.monotonic() < _token_expiry: if time.monotonic() < _token_expiry:
return _token return _token
session = _get_session() session = _get_session()
try:
async with session.post( async with session.post(
f"{PB_URL}/api/collections/_superusers/auth-with-password", f"{PB_URL}/api/collections/_superusers/auth-with-password",
json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD}, json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD},
) as resp: ) as resp:
if resp.status != 200: if resp.status != 200:
text = await resp.text() text = await resp.text()
raise DatabaseError(f"PocketBase auth failed ({resp.status}): {text}") raise RuntimeError(f"PocketBase auth failed ({resp.status}): {text}")
data = await resp.json() data = await resp.json()
_token = data["token"] _token = data["token"]
_token_expiry = time.monotonic() + 13 * 24 * 3600 # refresh well before expiry _token_expiry = time.monotonic() + 13 * 24 * 3600 # refresh well before expiry
_log.debug("PocketBase admin token refreshed") _log.debug("PocketBase admin token refreshed")
except (aiohttp.ClientConnectorError, asyncio.TimeoutError) as e:
raise DatabaseError(f"Database unavailable: {e}") from e
return _token return _token
@@ -83,35 +75,8 @@ async def _hdrs() -> dict[str, str]:
return {"Authorization": await _ensure_auth()} return {"Authorization": await _ensure_auth()}
def _invalidate_token() -> None: def _escape_filter_value(value: str) -> str:
global _token_expiry return value.replace("\\", "\\\\").replace('"', '\\"')
_token_expiry = 0.0
# ---------------------------------------------------------------------------
# Request helper with auth-retry and error wrapping
# ---------------------------------------------------------------------------
async def _request(method: str, url: str, **kwargs: Any) -> Any:
"""Make an authenticated request, retrying once on 401/403 by re-authing.
Returns the parsed JSON body. Raises DatabaseError on connection issues or
non-2xx responses after retrying.
"""
session = _get_session()
for attempt in range(2):
kwargs["headers"] = await _hdrs()
try:
async with session.request(method, url, **kwargs) as resp:
if resp.status in (401, 403) and attempt == 0:
_invalidate_token()
continue
if not resp.ok:
text = await resp.text()
raise DatabaseError(f"Database unavailable: {resp.status}, {text}")
return await resp.json()
except (aiohttp.ClientConnectorError, asyncio.TimeoutError) as e:
raise DatabaseError(f"Database unavailable: {e}") from e
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -120,55 +85,156 @@ async def _request(method: str, url: str, **kwargs: Any) -> Any:
async def get_record(user_id: str) -> dict[str, Any] | None: async def get_record(user_id: str) -> dict[str, Any] | None:
"""Fetch one economy record by Discord user_id. Returns None if not found.""" """Fetch one economy record by Discord user_id. Returns None if not found."""
data = await _request( return await get_first_record(
"GET", ECONOMY_COLLECTION,
f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records", f'user_id="{_escape_filter_value(user_id)}"',
params={"filter": f'user_id="{user_id}"', "perPage": 1},
) )
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/{collection}/records",
params={"filter": filter_expr, "perPage": 1},
headers=await _hdrs(),
) as resp:
resp.raise_for_status()
data = await resp.json()
items = data.get("items", []) items = data.get("items", [])
return items[0] if items else 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]: async def create_record(record: dict[str, Any]) -> dict[str, Any]:
"""Create a new economy record. Returns the created record (includes PB id).""" """Create a new economy record. Returns the created record (includes PB id)."""
return await _request( return await create_record_in(ECONOMY_COLLECTION, record)
"POST",
f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records",
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/{collection}/records",
json=record, json=record,
) headers=await _hdrs(),
) as resp:
if resp.status not in (200, 201):
text = await resp.text()
raise RuntimeError(f"PocketBase create failed ({resp.status}): {text}")
return await resp.json()
async def update_record(record_id: str, data: 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.""" """PATCH an existing record by its PocketBase record id."""
return await _request( return await update_record_in(ECONOMY_COLLECTION, record_id, data)
"PATCH",
f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records/{record_id}",
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/{collection}/records/{record_id}",
json=data, json=data,
) headers=await _hdrs(),
) as resp:
resp.raise_for_status()
return await resp.json()
async def count_records() -> int: async def count_records() -> int:
"""Return the total number of records in the collection (single cheap request).""" """Return the total number of records in the collection (single cheap request)."""
data = await _request( return await count_records_in(ECONOMY_COLLECTION)
"GET",
f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records",
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/{collection}/records",
params={"perPage": 1, "page": 1}, params={"perPage": 1, "page": 1},
) headers=await _hdrs(),
) as resp:
resp.raise_for_status()
data = await resp.json()
return int(data.get("totalItems", 0)) return int(data.get("totalItems", 0))
async def list_all_records(page_size: int = 500) -> list[dict[str, Any]]: async def list_all_records(page_size: int = 500) -> list[dict[str, Any]]:
"""Fetch every record in the collection, handling PocketBase pagination.""" """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]] = [] results: list[dict[str, Any]] = []
page = 1 page = 1
session = _get_session()
hdrs = await _hdrs()
while True: while True:
data = await _request( async with session.get(
"GET", f"{PB_URL}/api/collections/{collection}/records",
f"{PB_URL}/api/collections/{ECONOMY_COLLECTION}/records",
params={"perPage": page_size, "page": page}, params={"perPage": page_size, "page": page},
) headers=hdrs,
) as resp:
resp.raise_for_status()
data = await resp.json()
batch = data.get("items", []) batch = data.get("items", [])
results.extend(batch) results.extend(batch)
if len(batch) < page_size: if len(batch) < page_size:
return results break
page += 1 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

79
docs/LAN_FIENTA_SETUP.md Normal file
View File

@@ -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=<optional-long-random-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/<FIENTA_WEBHOOK_SECRET>
```
## 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.

View File

@@ -1,10 +0,0 @@
# TipiBOT changelog
Here you'll find an overview of TipiBOT updates. Latest changes are at the top.
Format each version with a `## ` header (e.g. `## v0.1.0 — 2026-05-03`).
## v0.1.0 — 2026-05-03
- Added `/patchnotes`
- Fixed silent swallowing of database write errors — failed saves now show the user an error instead of appearing to succeed
- Fixed fish-sell bug that let the last fish be duplicated (sold and kept in inventory)

View File

@@ -1,4 +1,4 @@
"""Destructively recreate economy PocketBase collections for dev + economy profiles. """Destructively recreate TipiBOT PocketBase collections.
Usage: Usage:
python scripts/reset_pb_collections.py --confirm python scripts/reset_pb_collections.py --confirm
@@ -6,6 +6,8 @@ Usage:
This will DELETE and recreate the collections configured by: This will DELETE and recreate the collections configured by:
- PB_ECONOMY_COLLECTION_DEV - PB_ECONOMY_COLLECTION_DEV
- PB_ECONOMY_COLLECTION_ECONOMY - PB_ECONOMY_COLLECTION_ECONOMY
- PB_ECONOMY_COLLECTION_LAN
- PB_FIENTA_COLLECTION_LAN
""" """
from __future__ import annotations 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 def _auth_token(session: aiohttp.ClientSession) -> str:
async with session.post( async with session.post(
f"{PB_URL}/api/collections/_superusers/auth-with-password", 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}") print(f"[DELETE] {name}")
async def _create_collection(session: aiohttp.ClientSession, headers: dict[str, str], name: str) -> None: async def _create_collection(
payload = _collection_payload(name) 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: async with session.post(f"{PB_URL}/api/collections", json=payload, headers=headers) as resp:
if resp.status not in (200, 201): if resp.status not in (200, 201):
raise RuntimeError(f"Create failed for {name} ({resp.status}): {await resp.text()}") raise RuntimeError(f"Create failed for {name} ({resp.status}): {await resp.text()}")
@@ -154,10 +205,17 @@ async def main() -> None:
if not args.confirm: if not args.confirm:
raise SystemExit("Refusing to run without --confirm (this operation deletes collections).") raise SystemExit("Refusing to run without --confirm (this operation deletes collections).")
targets = [] targets: list[tuple[str, dict]] = []
for name in [config.PB_ECONOMY_COLLECTION_DEV, config.PB_ECONOMY_COLLECTION_ECONOMY]: for name in [
if name and name not in targets: config.PB_ECONOMY_COLLECTION_DEV,
targets.append(name) 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: if not targets:
raise SystemExit("No target collections configured.") raise SystemExit("No target collections configured.")
@@ -167,12 +225,12 @@ async def main() -> None:
token = await _auth_token(session) token = await _auth_token(session)
headers = {"Authorization": token} headers = {"Authorization": token}
for name in targets: for name, payload in targets:
await _delete_if_exists(session, headers, name) await _delete_if_exists(session, headers, name)
await _create_collection(session, headers, name) await _create_collection(session, headers, name, payload)
print("\nDone. Collections recreated:") print("\nDone. Collections recreated:")
for name in targets: for name, _ in targets:
print(f" - {name}") print(f" - {name}")

View File

@@ -171,7 +171,7 @@ CMD: dict[str, str] = {
"fish": "Mine kalastama (interaktiivne mäng, 2min ooteaeg)", "fish": "Mine kalastama (interaktiivne mäng, 2min ooteaeg)",
"fishbook": "Vaata oma kalakogu ja kogutud kalaliike", "fishbook": "Vaata oma kalakogu ja kogutud kalaliike",
"fishsell": "Müü kalu oma inventarist", "fishsell": "Müü kalu oma inventarist",
"patchnotes": "Vaata TipiBOTi viimaseid muudatusi ja uuendusi", "fientasync": "[Admin] Sünkroniseeri LAN Fienta registreeringud uuesti",
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -307,6 +307,7 @@ HELP_CATEGORIES: dict[str, dict] = {
("/channels", "Näita lubatud kanalite nimekirja"), ("/channels", "Näita lubatud kanalite nimekirja"),
("/adminseason [top_n]", "Lõpeta võistlus, teavita võitjaid ja lähtesta EXP"), ("/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"), ("/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"),
], ],
}, },
} }
@@ -753,20 +754,6 @@ SEND_UI: dict[str, str] = {
"forbidden": "❌ Mul pole õigust kanalisse {channel} kirjutada.", "forbidden": "❌ Mul pole õigust kanalisse {channel} kirjutada.",
} }
# ---------------------------------------------------------------------------
# /patchnotes UI strings
# ---------------------------------------------------------------------------
PATCHNOTES_UI: dict[str, str] = {
"title": "📝 Muudatuste logi — {version}",
"footer": "Versioon {idx}/{total}",
"btn_newer": "◀ Uuem",
"btn_older": "Vanem ▶",
"select_placeholder": "Vali versioon…",
"empty_file": " Muudatuste logi on hetkel tühi.",
"empty_version": "_(selle versiooni kohta märkmeid pole)_",
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# /allowchannel /denychannel /channels UI strings # /allowchannel /denychannel /channels UI strings
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------