diff --git a/.env.example b/.env.example index ae4e597..f1b5c94 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,12 @@ -# Discord bot token (from https://discord.com/developers/applications) -DISCORD_TOKEN=your-bot-token-here +# Bot runtime profile: dev (economy + member tools) or economy (economy-only) +BOT_PROFILE=dev + +# 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 + +# 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 @@ -7,11 +14,21 @@ SHEET_ID=your-google-sheet-id-here # Path to Google service account credentials JSON GOOGLE_CREDS_PATH=credentials.json -# Guild (server) ID - right-click your server with dev mode on -GUILD_ID=your-guild-id-here +# 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 -# Channel ID where birthday announcements are posted (bot sends @here on the birthday at 09:00 Tallinn time) -BIRTHDAY_CHANNEL_ID=your-channel-id-here +# Legacy fallback guild ID (optional, backward compatibility) +GUILD_ID= + +# Channel ID where birthday announcements are posted (dev profile) +BIRTHDAY_CHANNEL_ID_DEV=your-dev-birthday-channel-id-here + +# Optional birthday channel for economy profile (normally unset for economy-only bot) +BIRTHDAY_CHANNEL_ID_ECONOMY= + +# Legacy fallback birthday channel ID (optional, backward compatibility) +BIRTHDAY_CHANNEL_ID= # How many days before a birthday the on-join check counts as "coming up" BIRTHDAY_WINDOW_DAYS=7 @@ -20,3 +37,10 @@ BIRTHDAY_WINDOW_DAYS=7 PB_URL=http://127.0.0.1:8090 PB_ADMIN_EMAIL=admin@example.com 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 + +# Legacy fallback collection name (optional, backward compatibility) +PB_ECONOMY_COLLECTION= diff --git a/README.md b/README.md index f47912e..492522a 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ The economy system stores all player data in [PocketBase](https://pocketbase.io/ 1. Download `pocketbase.exe` (Windows) from https://pocketbase.io/docs/ and place it in the project root. 2. Start PocketBase: `.\pocketbase.exe serve` 3. Open the admin UI at http://127.0.0.1:8090/_/ and create a superuser account. -4. Create a collection named `economy_users` - see `docs/POCKETBASE_SETUP.md` for the full schema. +4. Create two collections for profile separation: `economy_users_dev` and `economy_users_prod` - see `docs/POCKETBASE_SETUP.md` for schema notes. 5. Set `PB_URL`, `PB_ADMIN_EMAIL`, `PB_ADMIN_PASSWORD` in `.env`. 6. **One-time data migration** (only if you have an existing `data/economy.json`): `python scripts/migrate_to_pb.py` @@ -88,15 +88,25 @@ cp .env.example .env | Variable | Description | |---|---| -| `DISCORD_TOKEN` | Bot token from Discord Developer Portal | +| `BOT_PROFILE` | Runtime profile: `dev` or `economy` | +| `DISCORD_TOKEN_DEV` | Dev bot token from Discord Developer Portal | +| `DISCORD_TOKEN_ECONOMY` | Economy bot token from Discord Developer Portal | +| `DISCORD_TOKEN` | Legacy fallback token (optional) | | `SHEET_ID` | ID from the Google Sheet URL | | `GOOGLE_CREDS_PATH` | Path to `credentials.json` (default: `credentials.json`) | -| `GUILD_ID` | Right-click server → Copy Server ID (Developer Mode on) | -| `BIRTHDAY_CHANNEL_ID` | Channel for birthday `@here` pings | +| `GUILD_ID_DEV` | Dev bot guild ID | +| `GUILD_ID_ECONOMY` | Economy 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 | +| `BIRTHDAY_CHANNEL_ID` | Legacy fallback birthday channel ID (optional) | | `BIRTHDAY_WINDOW_DAYS` | Days before birthday that on-join check flags it (default: 7) | | `PB_URL` | PocketBase base URL (default: `http://127.0.0.1:8090`) | | `PB_ADMIN_EMAIL` | PocketBase superuser e-mail | | `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` | Legacy fallback collection (optional) | ### 6. Install & Run @@ -110,7 +120,12 @@ pip install -r requirements.txt # Terminal 1 - keep running .\pocketbase.exe serve -# Terminal 2 +# Terminal 2 (dev bot) +set BOT_PROFILE=dev +python bot.py + +# Terminal 3 (economy bot) +set BOT_PROFILE=economy python bot.py ``` @@ -146,6 +161,8 @@ Admins (bot lacks permission to modify them) are silently skipped and still mark ## Admin Commands > These commands are hidden from users who don't have the required permission. Override per-role in **Server Settings → Integrations → Bot**. +> +> Profile note: `/check`, `/member`, and `/birthdays` are available only when `BOT_PROFILE=dev`. | Command | Permission | What it does | |---|---|---| diff --git a/bot.py b/bot.py index aa71e33..72f4047 100644 --- a/bot.py +++ b/bot.py @@ -7,11 +7,8 @@ import json import logging import logging.handlers import math -import os import random import re -import subprocess -import sys import time from pathlib import Path from zoneinfo import ZoneInfo @@ -27,15 +24,20 @@ import config import strings as S import economy import pb_client -import member_sync import sheets -from member_sync import SyncResult, sync_member, announce_birthday, is_birthday_today +from dev_member_commands import register_dev_member_commands +from dev_member_runtime import handle_member_join, run_birthday_daily +from economy_admin_commands import register_economy_admin_commands +from economy_prestige_commands import register_prestige_commands +from ops_channel_commands import register_ops_channel_commands +from ops_admin_commands import register_ops_admin_commands +from member_sync import SyncResult # --------------------------------------------------------------------------- # Logging # --------------------------------------------------------------------------- -_LOG_DIR = Path("logs") -_LOG_DIR.mkdir(exist_ok=True) +_LOG_DIR = Path("logs") / config.BOT_PROFILE +_LOG_DIR.mkdir(parents=True, exist_ok=True) _fmt = logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s") _txn_fmt = logging.Formatter("%(asctime)s | %(message)s", datefmt="%Y-%m-%d %H:%M:%S") @@ -88,10 +90,12 @@ bot = discord.Client(intents=intents) tree = app_commands.CommandTree(bot) GUILD_OBJ = discord.Object(id=config.GUILD_ID) +IS_DEV_PROFILE = config.BOT_PROFILE == "dev" TALLINN_TZ = ZoneInfo("Europe/Tallinn") _start_time = datetime.datetime.now() _process = psutil.Process() -_DATA_DIR = Path("data") +_DATA_DIR = Path("data") / config.BOT_PROFILE +_DATA_DIR.mkdir(parents=True, exist_ok=True) _active_games: set[int] = set() # users with an in-progress interactive game _active_heist: "HeistLobbyView | None" = None # server-wide singleton # heist global CD is persisted on the house record in PocketBase (see economy.get/set_heist_global_cd) @@ -105,6 +109,17 @@ _BDAY_LOG = _DATA_DIR / "birthday_sent.json" _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") + + +def _apply_profile_command_filters() -> None: + """Remove commands that should not exist for the active bot profile.""" + if IS_DEV_PROFILE: + return + for name in _DEV_ONLY_COMMANDS: + removed = tree.remove_command(name) + if removed: + log.info("Profile '%s': removed dev-only command /%s", config.BOT_PROFILE, name) def _load_bot_config() -> dict: @@ -131,6 +146,19 @@ def _set_allowed_channels(channel_ids: list[int]) -> None: _save_bot_config(cfg) +def _get_paused() -> bool: + return _PAUSED + + +def _set_paused(value: bool) -> None: + global _PAUSED + _PAUSED = value + + +def _member_cache_size() -> int: + return len(sheets.get_cache()) + + # --------------------------------------------------------------------------- # EXP / Level role helpers # --------------------------------------------------------------------------- @@ -278,153 +306,20 @@ def _mark_announced_today(discord_id: int) -> None: _BDAY_LOG.write_text(json.dumps(log_data), encoding="utf-8") -# --------------------------------------------------------------------------- -# Birthday pages view -# --------------------------------------------------------------------------- -_MONTHS_ET = [ - "Jaanuar", "Veebruar", "Märts", "Aprill", "Mai", "Juuni", - "Juuli", "August", "September", "Oktoober", "November", "Detsember", -] - - -class BirthdayPages(discord.ui.View): - def __init__(self, pages: list[discord.Embed], start: int = 0): - super().__init__(timeout=120) - self.pages = pages - self.current = start - self._update_buttons() - - def _update_buttons(self): - self.prev_button.disabled = self.current == 0 - self.next_button.disabled = self.current >= len(self.pages) - 1 - - @discord.ui.button(label="◀", style=discord.ButtonStyle.secondary) - async def prev_button(self, interaction: discord.Interaction, button: discord.ui.Button): - self.current -= 1 - self._update_buttons() - await interaction.response.edit_message(embed=self.pages[self.current], view=self) - - @discord.ui.button(label="▶", style=discord.ButtonStyle.secondary) - async def next_button(self, interaction: discord.Interaction, button: discord.ui.Button): - self.current += 1 - self._update_buttons() - await interaction.response.edit_message(embed=self.pages[self.current], view=self) - - -def _build_birthday_pages( - guild: discord.Guild | None = None, -) -> tuple[list[discord.Embed], int]: - """Build 12 monthly embeds (one per calendar month). - - Returns (pages, start_index) where start_index is the current month. - """ - rows = sheets.get_cache() - today = datetime.date.today() - - # Group entries by month (1-12) - by_month: dict[int, list[tuple[int, str, int | None]]] = {m: [] for m in range(1, 13)} - - for row in rows: - name = str(row.get("Nimi", "")).strip() - bday_str = str(row.get("Sünnipäev", "")).strip() - if not name or not bday_str or bday_str.strip().lower() in ("-", "x", "n/a", "none", "ei"): - continue - bday = None - for fmt in ["%d/%m/%Y", "%Y-%m-%d", "%m-%d"]: - try: - bday = datetime.datetime.strptime(bday_str, fmt).date() - break - except ValueError: - continue - if bday is None: - continue - raw_uid = str(row.get("User ID", "")).strip() - try: - uid = int(raw_uid) if raw_uid and raw_uid not in ("0", "-") else None - except ValueError: - uid = None - by_month[bday.month].append((bday.day, name, uid)) - - pages: list[discord.Embed] = [] - for month in range(1, 13): - entries = sorted(by_month[month], key=lambda x: x[0]) - embed = discord.Embed( - title=f"🎂 {_MONTHS_ET[month - 1]}", - color=0xf4a261, - ) - if not entries: - embed.description = S.BIRTHDAY_UI["no_entries"] - else: - lines = [] - for day, name, uid in entries: - try: - this_year = datetime.date(today.year, month, day) - except ValueError: - this_year = datetime.date(today.year, month, day - 1) - next_bday = this_year if this_year >= today else this_year.replace(year=today.year + 1) - days_until = (next_bday - today).days - if days_until == 0: - when = S.BIRTHDAY_UI["today"] - elif days_until == 1: - when = S.BIRTHDAY_UI["tomorrow"] - else: - when = S.BIRTHDAY_UI["in_days"].format(days=days_until) - display = name - if guild and uid: - m = guild.get_member(uid) - if m: - display = m.mention - lines.append(f"{display} - {day:02d}/{month:02d} · {when}") - embed.description = "\n".join(lines) - embed.set_footer(text=S.BIRTHDAY_UI["footer"].format(month=month, month_name=_MONTHS_ET[month - 1])) - pages.append(embed) - - return pages, today.month - 1 # 0-indexed start on current month - - # --------------------------------------------------------------------------- # Daily 09:00 Tallinn-time birthday task # --------------------------------------------------------------------------- @tasks.loop(time=datetime.time(hour=9, minute=0, tzinfo=ZoneInfo("Europe/Tallinn"))) async def birthday_daily(): """Announce birthdays every day at 09:00 Tallinn time.""" - guild = bot.get_guild(config.GUILD_ID) - if guild is None: - log.warning("Birthday task: guild %s not found", config.GUILD_ID) + if not IS_DEV_PROFILE: return - - try: - data = sheets.refresh() - except Exception as e: - log.error("Birthday task: sheet refresh failed: %s", e) - data = sheets.get_cache() - - announced = 0 - for row in data: - bday_str = str(row.get("Sünnipäev", "")).strip() - if not is_birthday_today(bday_str): - continue - - member = None - raw_id = str(row.get("User ID", "")).strip() - if raw_id: - try: - member = guild.get_member(int(raw_id)) - except ValueError: - pass - if member is None: - discord_name = str(row.get("Discord", "")).strip() - if discord_name: - member = discord.utils.find( - lambda m, n=discord_name: m.name.lower() == n.lower(), - guild.members, - ) - if member and not _has_announced_today(member.id): - await announce_birthday(member, bot) - _mark_announced_today(member.id) - announced += 1 - - log.info("Sünnipäeva ülesanne: teavitati %d liiget", announced) + await run_birthday_daily( + bot, + log, + has_announced_today=_has_announced_today, + mark_announced_today=_mark_announced_today, + ) @birthday_daily.before_loop @@ -483,12 +378,15 @@ async def on_ready(): log.info("Logged in as %s (ID: %s)", bot.user, bot.user.id) economy.set_house(bot.user.id) + _apply_profile_command_filters() + # Pull sheet data into cache - try: - data = sheets.refresh() - 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) + if IS_DEV_PROFILE: + try: + data = sheets.refresh() + 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) # Sync slash commands to the guild only; wipe any leftover global registrations tree.copy_global_to(guild=GUILD_OBJ) @@ -498,7 +396,7 @@ async def on_ready(): log.info("Slash commands synced to guild %s (global commands cleared)", config.GUILD_ID) # Start daily birthday task - if not birthday_daily.is_running(): + if IS_DEV_PROFILE and not birthday_daily.is_running(): birthday_daily.start() log.info("Birthday daily task started (fires 09:00 Tallinn time)") @@ -536,71 +434,52 @@ 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.""" - log.info("Member joined: %s (ID: %s)", member, member.id) - - # Make sure cache is populated - if not sheets.get_cache(): - sheets.refresh() - - result = await sync_member(member, member.guild) - - if result.not_found: - try: - sheets.add_new_member_row(member.name, member.id) - log.info(" → %s not in sheet, added new row (Discord=%s, ID=%s)", - member, member.name, member.id) - except Exception as e: - log.error(" → Failed to add sheet row for %s: %s", member, e) + if not IS_DEV_PROFILE: return - - _log_sync_result(member, result) - sheets.set_synced(member.id, result.synced) - - # Sünnipäeva teavitus - if result.birthday_soon and not _has_announced_today(member.id): - await announce_birthday(member, bot) - _mark_announced_today(member.id) + await handle_member_join( + member, + bot, + log, + has_announced_today=_has_announced_today, + mark_announced_today=_mark_announced_today, + log_sync_result=_log_sync_result, + ) # --------------------------------------------------------------------------- # Slash commands # --------------------------------------------------------------------------- -def _sheet_stats(rows: list[dict]) -> str: - """Return a formatted string with sheet completeness statistics.""" - total = len(rows) - missing_uid = [] - missing_discord = [] - missing_birthday = [] +if IS_DEV_PROFILE: + register_dev_member_commands( + tree, + bot, + log, + has_announced_today=_has_announced_today, + mark_announced_today=_mark_announced_today, + ) - for row in rows: - name = str(row.get("Nimi", "")).strip() or "(no name)" - uid = str(row.get("User ID", "")).strip() - discord_name = str(row.get("Discord", "")).strip() - bday = str(row.get("Sünnipäev", "")).strip() +register_ops_admin_commands( + tree, + bot, + log, + process=_process, + start_time=_start_time, + log_dir=_LOG_DIR, + guild_obj=GUILD_OBJ, + restart_file=_RESTART_FILE, + get_member_cache_size=_member_cache_size, + get_paused=_get_paused, + set_paused=_set_paused, + count_economy_users=pb_client.count_records, +) - if not uid or uid == "0": - missing_uid.append(name) - if not discord_name: - missing_discord.append(name) - if not bday: - missing_birthday.append(name) - - lines = [S.CHECK_UI["sheet_stats_header"].format(total=total)] - lines.append("") - - def stat_line(label: str, missing: list[str]) -> str: - count = len(missing) - if count == 0: - return S.CHECK_UI["stat_ok"].format(label=label) - names = ", ".join(missing[:5]) - more = S.CHECK_UI["stat_more"].format(count=count - 5) if count > 5 else "" - return S.CHECK_UI["stat_warn"].format(label=label, count=count, names=names, more=more) - - lines.append(stat_line(S.CHECK_UI["stat_uid"], missing_uid)) - lines.append(stat_line(S.CHECK_UI["stat_discord"], missing_discord)) - lines.append(stat_line(S.CHECK_UI["stat_bday"], missing_birthday)) - - return "\n".join(lines) +register_ops_channel_commands( + tree, + bot, + log, + get_allowed_channels=_get_allowed_channels, + set_allowed_channels=_set_allowed_channels, +) @tree.command(name="ping", description=S.CMD["ping"]) @@ -612,12 +491,28 @@ async def cmd_ping(interaction: discord.Interaction): # /help # --------------------------------------------------------------------------- _HELP_PAGE_SIZE = 10 +_DEV_ONLY_HELP_TOKENS: tuple[str, ...] = tuple(f"/{name}" for name in _DEV_ONLY_COMMANDS) + + +def _visible_help_fields(category_key: str) -> list[tuple[str, str]]: + fields: list[tuple[str, str]] = list(S.HELP_CATEGORIES[category_key]["fields"]) + if IS_DEV_PROFILE: + return fields + + visible: list[tuple[str, str]] = [] + for name, value in fields: + blob = f"{name}\n{value}".lower() + if any(tok in blob for tok in _DEV_ONLY_HELP_TOKENS): + continue + visible.append((name, value)) + return visible def _help_embed(category_key: str, page: int = 0) -> discord.Embed: cat = S.HELP_CATEGORIES[category_key] - fields = cat["fields"] - total_pages = math.ceil(len(fields) / _HELP_PAGE_SIZE) + fields = _visible_help_fields(category_key) + total_pages = max(1, math.ceil(len(fields) / _HELP_PAGE_SIZE)) + page = max(0, min(page, total_pages - 1)) page_fields = fields[page * _HELP_PAGE_SIZE : (page + 1) * _HELP_PAGE_SIZE] title = cat["label"] if total_pages > 1: @@ -639,8 +534,9 @@ class HelpView(discord.ui.View): def _rebuild(self) -> None: self.clear_items() - fields = S.HELP_CATEGORIES[self.category]["fields"] - total_pages = math.ceil(len(fields) / _HELP_PAGE_SIZE) + fields = _visible_help_fields(self.category) + total_pages = max(1, math.ceil(len(fields) / _HELP_PAGE_SIZE)) + self.page = max(0, min(self.page, total_pages - 1)) select_row = 0 if total_pages > 1: select_row = 1 @@ -664,7 +560,7 @@ class HelpView(discord.ui.View): await interaction.response.edit_message(embed=_help_embed(self.category, self.page), view=self) async def _next(self, interaction: discord.Interaction) -> None: - total = math.ceil(len(S.HELP_CATEGORIES[self.category]["fields"]) / _HELP_PAGE_SIZE) + total = max(1, math.ceil(len(_visible_help_fields(self.category)) / _HELP_PAGE_SIZE)) self.page = min(total - 1, self.page + 1) self._rebuild() await interaction.response.edit_message(embed=_help_embed(self.category, self.page), view=self) @@ -699,534 +595,6 @@ async def cmd_help(interaction: discord.Interaction): ) -@tree.command(name="status", description=S.CMD["status"]) -@app_commands.guild_only() -@app_commands.default_permissions(manage_guild=True) -async def cmd_staatus(interaction: discord.Interaction): - proc = _process - mem = proc.memory_info() - cpu = proc.cpu_percent(interval=0.1) - uptime = datetime.datetime.now() - _start_time - h, rem = divmod(int(uptime.total_seconds()), 3600) - m, s = divmod(rem, 60) - tasks_count = len(asyncio.all_tasks()) - latency_ms = round(bot.latency * 1000, 1) - cache = sheets.get_cache() - - data = await economy.get_leaderboard(top_n=9999) - user_count = len(data) - - embed = discord.Embed(title="🖥️ Boti olek", color=0x57F287) - embed.add_field(name="🕐 Uptime", value=f"{h}t {m}m {s}s", inline=True) - embed.add_field(name="📡 Latency", value=f"{latency_ms} ms", inline=True) - embed.add_field(name="🧠 RAM (RSS)", value=f"{mem.rss / 1024**2:.1f} MB", inline=True) - embed.add_field(name="⚙️ CPU", value=f"{cpu:.1f}%", inline=True) - embed.add_field(name="🔄 Async tasks", value=str(tasks_count), inline=True) - embed.add_field(name="👤 Eco players", value=str(user_count), inline=True) - embed.add_field(name="📋 Liikmed (cache)", value=str(len(cache)), inline=True) - embed.add_field( - name="📂 Log files", - value="\n".join( - f"`{p.name}` - {p.stat().st_size / 1024:.1f} KB" - for p in sorted(_LOG_DIR.glob("*.log*")) - if p.is_file() - ) or "-", - inline=False, - ) - await interaction.response.send_message(embed=embed, ephemeral=True) - - -@tree.command(name="birthdays", description=S.CMD["birthdays"]) -@app_commands.guild_only() -async def cmd_birthdays(interaction: discord.Interaction): - await interaction.response.defer() - - try: - sheets.refresh() - except Exception as e: - await interaction.followup.send(S.ERR["sheet_error"].format(error=e), ephemeral=True) - return - - pages, start = _build_birthday_pages(guild=interaction.guild) - await interaction.followup.send(embed=pages[start], view=BirthdayPages(pages, start=start)) - - -@tree.command(name="check", description=S.CMD["check"]) -@app_commands.guild_only() -@app_commands.default_permissions(manage_roles=True) -async def cmd_check(interaction: discord.Interaction): - await interaction.response.defer(ephemeral=True) - - guild = interaction.guild - if guild is None: - await interaction.followup.send(S.ERR["guild_only"], ephemeral=True) - return - - # Load fresh sheet data - try: - data = sheets.refresh() - except Exception as e: - await interaction.followup.send(S.ERR["sheet_error"].format(error=e), ephemeral=True) - return - - # Backfill missing User IDs by matching Discord username - ids_filled = 0 - for row in data: - uid = str(row.get("User ID", "")).strip() - if uid and uid not in ("0", "-"): - continue - discord_name = str(row.get("Discord", "")).strip() - if not discord_name: - continue - gm = discord.utils.find( - lambda m, n=discord_name: m.name.lower() == n.lower(), - guild.members, - ) - if gm: - sheets.set_user_id(discord_name, gm.id) - ids_filled += 1 - - data = sheets.get_cache() - - changed_count = 0 - not_found = 0 - already_ok = 0 - errors_total = 0 - birthday_pings = 0 - details: list[str] = [] - sync_updates: list[tuple[int, bool]] = [] - - members = guild.members - for member in members: - if member.bot: - continue - - result = await sync_member(member, guild) - - if result.not_found: - not_found += 1 - continue - - sync_updates.append((member.id, result.synced)) - - if result.errors: - errors_total += len(result.errors) - for err in result.errors: - details.append(f"⚠️ {err}") - - if result.changed: - changed_count += 1 - parts = [] - if result.nickname_changed: - parts.append("hüüdnimi") - if result.roles_added: - parts.append(f"+rollid: {', '.join(result.roles_added)}") - details.append(f"🔧 **{member.display_name}**: {', '.join(parts)}") - else: - already_ok += 1 - - if result.birthday_soon and not _has_announced_today(member.id): - birthday_pings += 1 - await announce_birthday(member, bot) - _mark_announced_today(member.id) - - # Batch-write synced status (single API call instead of one per member) - if sync_updates: - try: - sheets.batch_set_synced(sync_updates) - except Exception as e: - log.error("/check batch_set_synced failed: %s", e) - - # Build summary - summary_lines = [ - S.CHECK_UI["done"], - S.CHECK_UI["already_ok"].format(count=already_ok), - S.CHECK_UI["fixed"].format(count=changed_count), - S.CHECK_UI["not_found"].format(count=not_found), - S.CHECK_UI["bday_pings"].format(count=birthday_pings), - ] - if errors_total: - summary_lines.append(S.CHECK_UI["errors"].format(count=errors_total)) - - summary = "\n".join(summary_lines) - - if details: - detail_text = "\n".join(details[:20]) # cap at 20 to avoid message limit - summary += f"\n\n{S.CHECK_UI['details_header']}\n{detail_text}" - if len(details) > 20: - summary += "\n" + S.CHECK_UI["details_more"].format(count=len(details) - 20) - - stats = _sheet_stats(data) - id_note = S.CHECK_UI["ids_filled"].format(count=ids_filled) if ids_filled else "" - summary = id_note + "\n" + summary + "\n\n" + stats - - await interaction.followup.send(summary.strip(), ephemeral=True) - log.info("/check - OK=%d, Fixed=%d, NotFound=%d, IDs=%d, Errors=%d", - already_ok, changed_count, not_found, ids_filled, errors_total) - - -@tree.command(name="sync", description=S.CMD["sync"]) -@app_commands.guild_only() -@app_commands.default_permissions(manage_guild=True) -async def cmd_sync(interaction: discord.Interaction): - await interaction.response.defer(ephemeral=True) - tree.copy_global_to(guild=GUILD_OBJ) - await tree.sync(guild=GUILD_OBJ) - tree.clear_commands(guild=None) - await tree.sync() - await interaction.followup.send(S.MSG_SYNC_DONE, ephemeral=True) - log.info("/sync triggered by %s", interaction.user) - - -@tree.command(name="adminseason", description=S.CMD["adminseason"]) -@app_commands.guild_only() -@app_commands.default_permissions(manage_guild=True) -@app_commands.describe(top_n=S.OPT["adminseason_top_n"]) -async def cmd_adminseason(interaction: discord.Interaction, top_n: int = 10): - await interaction.response.defer(ephemeral=True) - top = await economy.do_season_reset(top_n) - guild = interaction.guild - - # Strip all vanity roles from every guild member - if guild: - all_role_names = {name for _, name in economy.LEVEL_ROLES} - for role_name in all_role_names: - role = discord.utils.find(lambda r: r.name == role_name, guild.roles) - if not role: - continue - for m in list(role.members): - try: - await m.remove_roles(role, reason="Season reset") - except discord.Forbidden: - pass - - medals = ["\U0001f947", "\U0001f948", "\U0001f949"] - lines = [] - for i, (uid, exp, lvl) in enumerate(top): - member = guild.get_member(int(uid)) if guild else None - name = member.display_name if member else S.LEADERBOARD_UI["unknown_user"].format(uid=uid) - prefix = medals[i] if i < 3 else f"**{i + 1}.**" - lines.append(S.SEASON["entry"].format(prefix=prefix, name=name, exp=exp, level=lvl)) - - embed = discord.Embed( - title=S.TITLE["adminseason"], - description=(S.SEASON["top_header"] + "\n" + "\n".join(lines)) if lines else S.SEASON["no_players"], - color=0xF4C430, - ) - embed.set_footer(text=S.SEASON["footer"]) - await interaction.followup.send(embed=embed, ephemeral=False) - await interaction.followup.send(S.SEASON["done"], ephemeral=True) - log.info("/adminseason triggered by %s (top_n=%d)", interaction.user, top_n) - - -@tree.command(name="member", description=S.CMD["member"]) -@app_commands.guild_only() -@app_commands.default_permissions(manage_roles=True) -async def cmd_member(interaction: discord.Interaction, user: discord.Member): - row = sheets.find_member(user.id, user.name) - if row is None: - await interaction.response.send_message( - S.ERR["member_not_found"].format(name=user.display_name), - ephemeral=True, - ) - return - - embed = discord.Embed(title=S.MEMBER_UI["title"].format(name=user.display_name), color=user.color) - - # Age from birthday - bday_str = str(row.get("Sünnipäev", "")).strip() - if bday_str and bday_str.lower() not in ("-", "x", "n/a", "none", "ei"): - for fmt in ["%d/%m/%Y", "%Y-%m-%d"]: - try: - bday = datetime.datetime.strptime(bday_str, fmt).date() - if 1920 <= bday.year <= datetime.date.today().year: - today = datetime.date.today() - age = today.year - bday.year - ((today.month, today.day) < (bday.month, bday.day)) - embed.add_field(name=S.MEMBER_UI["age_field"], value=S.MEMBER_UI["age_val"].format(age=age), inline=True) - break - except ValueError: - continue - - for sheet_key, label in S.MEMBER_FIELDS: - val = str(row.get(sheet_key, "")).strip() - if val: - embed.add_field(name=label, value=val, inline=True) - - await interaction.response.send_message(embed=embed, ephemeral=True) - - -@tree.command(name="restart", description=S.CMD["restart"]) -@app_commands.guild_only() -@app_commands.default_permissions(manage_guild=True) -async def cmd_restart(interaction: discord.Interaction): - _RESTART_FILE.write_text( - json.dumps({"channel_id": interaction.channel_id}), encoding="utf-8" - ) - await interaction.response.send_message(S.MSG_RESTARTING, ephemeral=True) - log.info("/restart triggered by %s", interaction.user) - subprocess.Popen([sys.executable] + sys.argv, cwd=os.getcwd()) - await bot.close() - - -@tree.command(name="shutdown", description=S.CMD["shutdown"]) -@app_commands.guild_only() -@app_commands.default_permissions(manage_guild=True) -async def cmd_shutdown(interaction: discord.Interaction): - await interaction.response.send_message(S.MSG_SHUTTING_DOWN, ephemeral=True) - log.info("/shutdown triggered by %s", interaction.user) - await bot.close() - - -@tree.command(name="pause", description=S.CMD["pause"]) -@app_commands.guild_only() -@app_commands.default_permissions(manage_guild=True) -async def cmd_pause(interaction: discord.Interaction): - global _PAUSED - _PAUSED = not _PAUSED - msg = S.MSG_PAUSED if _PAUSED else S.MSG_UNPAUSED - log.info("/pause toggled → %s by %s", "PAUSED" if _PAUSED else "UNPAUSED", interaction.user) - await interaction.response.send_message(msg, ephemeral=True) - - -# --------------------------------------------------------------------------- -# Admin economy commands -# --------------------------------------------------------------------------- -async def _dm_user(user_id: int, msg: str) -> None: - """Best-effort DM to a user.""" - try: - user = bot.get_user(user_id) or await bot.fetch_user(user_id) - await user.send(msg) - except Exception: - pass - - -@tree.command(name="admincoins", description=S.CMD["admincoins"]) -@app_commands.guild_only() -@app_commands.describe( - kasutaja=S.OPT["admin_kasutaja"], - kogus=S.OPT["admincoins_kogus"], - põhjus=S.OPT["admin_põhjus"], -) -@app_commands.default_permissions(manage_guild=True) -async def cmd_admincoins(interaction: discord.Interaction, kasutaja: discord.Member, kogus: int, põhjus: str): - if kogus == 0: - await interaction.response.send_message(S.ERR["admincoins_zero"], ephemeral=True) - return - res = await economy.do_admin_coins(kasutaja.id, kogus, interaction.user.id, põhjus) - verb = f"+{kogus}" if kogus > 0 else str(kogus) - emoji = "💰" if kogus > 0 else "💸" - await interaction.response.send_message( - S.ADMIN["coins_done"].format(emoji=emoji, name=kasutaja.display_name, verb=verb, coin=economy.COIN, balance=f"{res['balance']:,}", reason=põhjus), - ephemeral=True, - ) - await _dm_user(kasutaja.id, - S.ADMIN["coins_dm"].format(emoji=emoji, verb=verb, coin=economy.COIN, reason=põhjus, balance=f"{res['balance']:,}") - ) - log.info("ADMINCOINS %s → %s (%s) by %s", verb, kasutaja, põhjus, interaction.user) - - -@tree.command(name="adminjail", description=S.CMD["adminjail"]) -@app_commands.guild_only() -@app_commands.describe( - kasutaja=S.OPT["admin_kasutaja"], - minutid=S.OPT["adminjail_minutid"], - põhjus=S.OPT["admin_põhjus"], -) -@app_commands.default_permissions(manage_guild=True) -async def cmd_adminjail(interaction: discord.Interaction, kasutaja: discord.Member, minutid: int, põhjus: str): - if minutid <= 0: - await interaction.response.send_message(S.ERR["positive_duration"], ephemeral=True) - return - res = await economy.do_admin_jail(kasutaja.id, minutid, interaction.user.id, põhjus) - until_ts = _cd_ts(datetime.timedelta(minutes=minutid)) - await interaction.response.send_message( - S.ADMIN["jail_done"].format(name=kasutaja.display_name, minutes=minutid, ts=until_ts, reason=põhjus), - ephemeral=True, - ) - await _dm_user(kasutaja.id, - S.ADMIN["jail_dm"].format(minutes=minutid, reason=põhjus, ts=until_ts) - ) - log.info("ADMINJAIL %s %dmin (%s) by %s", kasutaja, minutid, põhjus, interaction.user) - - -@tree.command(name="adminunjail", description=S.CMD["adminunjail"]) -@app_commands.guild_only() -@app_commands.describe(kasutaja=S.OPT["admin_kasutaja"]) -@app_commands.default_permissions(manage_guild=True) -async def cmd_adminunjail(interaction: discord.Interaction, kasutaja: discord.Member): - await economy.do_admin_unjail(kasutaja.id, interaction.user.id) - await interaction.response.send_message( - S.ADMIN["unjail_done"].format(name=kasutaja.display_name), ephemeral=True - ) - await _dm_user(kasutaja.id, S.ADMIN["unjail_dm"]) - log.info("ADMINUNJAIL %s by %s", kasutaja, interaction.user) - - -@tree.command(name="adminban", description=S.CMD["adminban"]) -@app_commands.guild_only() -@app_commands.describe( - kasutaja=S.OPT["admin_kasutaja"], - põhjus=S.OPT["admin_põhjus"], -) -@app_commands.default_permissions(manage_guild=True) -async def cmd_adminban(interaction: discord.Interaction, kasutaja: discord.Member, põhjus: str): - if bot.user and kasutaja.id == bot.user.id: - await interaction.response.send_message(S.ERR["admin_ban_bot"], ephemeral=True) - return - await economy.do_admin_ban(kasutaja.id, interaction.user.id, põhjus) - await interaction.response.send_message( - S.ADMIN["ban_done"].format(name=kasutaja.display_name, reason=põhjus), - ephemeral=True, - ) - await _dm_user(kasutaja.id, - S.ADMIN["ban_dm"].format(reason=põhjus) - ) - log.info("ADMINBAN %s (%s) by %s", kasutaja, põhjus, interaction.user) - - -@tree.command(name="adminunban", description=S.CMD["adminunban"]) -@app_commands.guild_only() -@app_commands.describe(kasutaja=S.OPT["admin_kasutaja"]) -@app_commands.default_permissions(manage_guild=True) -async def cmd_adminunban(interaction: discord.Interaction, kasutaja: discord.Member): - await economy.do_admin_unban(kasutaja.id, interaction.user.id) - await interaction.response.send_message( - S.ADMIN["unban_done"].format(name=kasutaja.display_name), ephemeral=True - ) - await _dm_user(kasutaja.id, S.ADMIN["unban_dm"]) - log.info("ADMINUNBAN %s by %s", kasutaja, interaction.user) - - -@tree.command(name="adminreset", description=S.CMD["adminreset"]) -@app_commands.guild_only() -@app_commands.describe( - kasutaja=S.OPT["admin_kasutaja"], - põhjus=S.OPT["admin_põhjus"], -) -@app_commands.default_permissions(manage_guild=True) -async def cmd_adminreset(interaction: discord.Interaction, kasutaja: discord.Member, põhjus: str): - if bot.user and kasutaja.id == bot.user.id: - await interaction.response.send_message(S.ERR["admin_reset_bot"], ephemeral=True) - return - await economy.do_admin_reset(kasutaja.id, interaction.user.id) - await interaction.response.send_message( - S.ADMIN["reset_done"].format(name=kasutaja.display_name, reason=põhjus), - ephemeral=True, - ) - await _dm_user(kasutaja.id, - S.ADMIN["reset_dm"].format(reason=põhjus) - ) - log.info("ADMINRESET %s (%s) by %s", kasutaja, põhjus, interaction.user) - - -@tree.command(name="adminview", description=S.CMD["adminview"]) -@app_commands.guild_only() -@app_commands.describe(kasutaja=S.OPT["admin_kasutaja"]) -@app_commands.default_permissions(manage_guild=True) -async def cmd_adminview(interaction: discord.Interaction, kasutaja: discord.Member): - res = await economy.do_admin_inspect(kasutaja.id) - d = res["data"] - items_str = ", ".join(d.get("items", [])) or "-" - uses = d.get("item_uses", {}) - uses_str = ", ".join(f"{k}:{v}" for k, v in uses.items()) if uses else "-" - jailed = d.get("jailed_until") or "-" - banned = S.ADMINVIEW_UI["banned_yes"] if d.get("eco_banned") else S.ADMINVIEW_UI["banned_no"] - exp = d.get("exp", 0) - level = economy.get_level(exp) - prestige_lvl = d.get("prestige_level", 0) - prestige_pp = d.get("prestige_points", 0) - total_fish = d.get("total_fish_caught", 0) - inv_fish = len(d.get("fish_inventory") or []) - embed = discord.Embed( - title=S.ADMINVIEW_UI["title"].format(name=kasutaja.display_name), - color=0x5865F2, - ) - embed.add_field(name=S.ADMINVIEW_UI["f_balance"], value=f"{d.get('balance', 0):,} {economy.COIN}", inline=True) - embed.add_field(name=S.ADMINVIEW_UI["f_exp"], value=S.ADMINVIEW_UI["exp_val"].format(exp=f"{exp:,}", level=level), inline=True) - embed.add_field(name=S.ADMINVIEW_UI["f_streak"], value=str(d.get("daily_streak", 0)), inline=True) - embed.add_field(name=S.ADMINVIEW_UI["f_banned"], value=banned, inline=True) - embed.add_field(name=S.ADMINVIEW_UI["f_jailed"], value=jailed, inline=True) - embed.add_field(name=S.ADMINVIEW_UI["f_prestige"], value=S.ADMINVIEW_UI["prestige_val"].format(level=prestige_lvl, pp=prestige_pp), inline=True) - embed.add_field(name=S.ADMINVIEW_UI["f_fish"], value=S.ADMINVIEW_UI["fish_val"].format(caught=total_fish, inv=inv_fish), inline=True) - embed.add_field(name=S.ADMINVIEW_UI["f_items"], value=items_str, inline=False) - embed.add_field(name=S.ADMINVIEW_UI["f_uses"], value=uses_str, inline=False) - embed.add_field(name=S.ADMINVIEW_UI["f_last_daily"], value=d.get("last_daily") or "-", inline=True) - embed.add_field(name=S.ADMINVIEW_UI["f_last_work"], value=d.get("last_work") or "-", inline=True) - embed.add_field(name=S.ADMINVIEW_UI["f_last_crime"], value=d.get("last_crime") or "-", inline=True) - embed.add_field(name=S.ADMINVIEW_UI["f_last_fish"], value=d.get("last_fish") or "-", inline=True) - embed.set_footer(text=S.ADMINVIEW_UI["footer"].format(uid=kasutaja.id)) - await interaction.response.send_message(embed=embed, ephemeral=True) - log.info("ADMINVIEW %s by %s", kasutaja, interaction.user) - - -@tree.command(name="adminexp", description=S.CMD["adminexp"]) -@app_commands.guild_only() -@app_commands.describe( - kasutaja=S.OPT["admin_kasutaja"], - kogus=S.OPT["adminexp_kogus"], - põhjus=S.OPT["admin_põhjus"], -) -@app_commands.default_permissions(manage_guild=True) -async def cmd_adminexp(interaction: discord.Interaction, kasutaja: discord.Member, kogus: int, põhjus: str): - if kogus == 0: - await interaction.response.send_message(S.ERR["admincoins_zero"], ephemeral=True) - return - res = await economy.do_admin_exp(kasutaja.id, kogus, interaction.user.id, põhjus) - verb = f"+{kogus}" if kogus > 0 else str(kogus) - emoji = "📈" if kogus > 0 else "📉" - await interaction.response.send_message( - S.ADMIN["exp_done"].format( - emoji=emoji, name=kasutaja.display_name, verb=verb, - exp=f"{res['exp']:,}", level=res["new_level"], reason=põhjus, - ), - ephemeral=True, - ) - await _dm_user(kasutaja.id, - S.ADMIN["exp_dm"].format( - emoji=emoji, verb=verb, reason=põhjus, - exp=f"{res['exp']:,}", level=res["new_level"], - ) - ) - if res["level_changed"]: - member = interaction.guild.get_member(kasutaja.id) if interaction.guild else None - if member: - await _apply_level_role(member, res["new_level"], res["old_level"]) - log.info("ADMINEXP %s → %s (%s) by %s", verb, kasutaja, põhjus, interaction.user) - - -@tree.command(name="adminitem", description=S.CMD["adminitem"]) -@app_commands.guild_only() -@app_commands.describe( - kasutaja=S.OPT["admin_kasutaja"], - ese=S.OPT["adminitem_ese"], - tegevus=S.OPT["adminitem_tegevus"], -) -@app_commands.default_permissions(manage_guild=True) -async def cmd_adminitem(interaction: discord.Interaction, kasutaja: discord.Member, ese: str, tegevus: str): - action = tegevus.strip().lower() - if action not in ("anna", "eemalda"): - await interaction.response.send_message(S.ADMIN["item_invalid"].format(item_id=tegevus), ephemeral=True) - return - action_key = "give" if action == "anna" else "remove" - res = await economy.do_admin_item(kasutaja.id, ese, action_key, interaction.user.id) - if not res["ok"]: - if res["reason"] == "invalid_item": - await interaction.response.send_message(S.ADMIN["item_invalid"].format(item_id=ese), ephemeral=True) - elif res["reason"] == "not_owned": - await interaction.response.send_message(S.ADMIN["item_not_owned"].format(name=kasutaja.display_name, item_id=ese), ephemeral=True) - else: - await interaction.response.send_message(S.ERR["generic_error"].format(error=res["reason"]), ephemeral=True) - return - item_name = economy.SHOP[ese]["name"] - if action_key == "give": - await interaction.response.send_message(S.ADMIN["item_given"].format(item=item_name, name=kasutaja.display_name), ephemeral=True) - await _dm_user(kasutaja.id, S.ADMIN["item_dm_given"].format(item=item_name)) - else: - await interaction.response.send_message(S.ADMIN["item_removed"].format(item=item_name, name=kasutaja.display_name), ephemeral=True) - await _dm_user(kasutaja.id, S.ADMIN["item_dm_removed"].format(item=item_name)) - log.info("ADMINITEM %s %s %s by %s", action_key, ese, kasutaja, interaction.user) - - # --------------------------------------------------------------------------- # TipiBOT economy commands # --------------------------------------------------------------------------- @@ -1241,6 +609,15 @@ def _cd_ts(remaining: datetime.timedelta) -> str: return f"" +register_economy_admin_commands( + tree, + bot, + log, + cd_ts=_cd_ts, + apply_level_role=_apply_level_role, +) + + def _gamble_cd(uid: int, has_360: bool = False) -> datetime.timedelta | None: """Check and set gambling cooldown. Returns remaining time if on CD, else None.""" cd = float(_GAMBLE_CD_360 if has_360 else _GAMBLE_CD) @@ -1268,6 +645,13 @@ async def _check_cmd_rate(interaction: discord.Interaction) -> bool: return False +register_prestige_commands( + tree, + check_cmd_rate=_check_cmd_rate, + ensure_level_role=_ensure_level_role, +) + + def _parse_amount(value: str, balance: int) -> tuple[int | None, str | None]: """Parse an amount string; 'all' resolves to the user's full balance. Accepts plain integers and valid thousand-separated numbers (1,000 / 1.000 / 1 000). @@ -4040,128 +3424,6 @@ async def cmd_reminders(interaction: discord.Interaction): await interaction.response.send_message(embed=embed, view=RemindersView(interaction.user.id, current), ephemeral=True) -@tree.command(name="send", description=S.CMD["send"]) -@app_commands.guild_only() -@app_commands.describe( - kanal=S.OPT["send_kanal"], - sõnum=S.OPT["send_sõnum"], -) -@app_commands.default_permissions(manage_guild=True) -async def cmd_send(interaction: discord.Interaction, kanal: discord.TextChannel, sõnum: str): - try: - await kanal.send(sõnum) - await interaction.response.send_message( - S.SEND_UI["sent"].format(channel=kanal.mention), ephemeral=True - ) - except discord.Forbidden: - await interaction.response.send_message( - S.SEND_UI["forbidden"].format(channel=kanal.mention), ephemeral=True - ) - except Exception as e: - await interaction.response.send_message( - S.ERR["send_failed"].format(error=e), ephemeral=True - ) - - -@tree.command(name="economysetup", description=S.CMD["economysetup"]) -@app_commands.guild_only() -@app_commands.default_permissions(manage_guild=True) -async def cmd_economysetup(interaction: discord.Interaction): - await interaction.response.defer(ephemeral=True) - guild = interaction.guild - bot_member = guild.get_member(bot.user.id) - bot_top_pos = max((r.position for r in bot_member.roles if r.managed), default=1) - - all_role_names = [economy.ECONOMY_ROLE] + [name for _, name in economy.LEVEL_ROLES] - - created, existing = [], [] - for name in all_role_names: - role = discord.utils.find(lambda r, n=name: r.name == n, guild.roles) - if role is None: - await guild.create_role(name=name, reason="/economysetup") - created.append(name) - else: - existing.append(name) - - positions: dict[discord.Role, int] = {} - base = max(bot_top_pos - 1, 1) - for i, name in enumerate(all_role_names): - role = discord.utils.find(lambda r, n=name: r.name == n, guild.roles) - if role: - positions[role] = max(base - i, 1) - if positions: - try: - await guild.edit_role_positions(positions=positions) - except discord.Forbidden: - pass - - embed = discord.Embed(title=S.TITLE["economysetup"], color=0x57F287) - if created: - embed.add_field(name=S.ECONOMYSETUP_UI["created_field"], value="\n".join(created), inline=True) - if existing: - embed.add_field(name=S.ECONOMYSETUP_UI["existing_field"], value="\n".join(existing), inline=True) - embed.set_footer(text=S.ECONOMYSETUP_UI["footer"]) - await interaction.followup.send(embed=embed, ephemeral=True) - log.info("/economysetup triggered by %s", interaction.user) - - -@tree.command(name="allowchannel", description=S.CMD["allowchannel"]) -@app_commands.guild_only() -@app_commands.describe(kanal=S.OPT["allowchannel_kanal"]) -@app_commands.default_permissions(manage_guild=True) -async def cmd_allowchannel(interaction: discord.Interaction, kanal: discord.TextChannel): - allowed = _get_allowed_channels() - if kanal.id in allowed: - await interaction.response.send_message( - S.CHANNEL_UI["already_allowed"].format(channel=kanal.mention), ephemeral=True - ) - return - allowed.append(kanal.id) - _set_allowed_channels(allowed) - log.info("ALLOWCHANNEL +%s by %s", kanal, interaction.user) - await interaction.response.send_message( - S.CHANNEL_UI["added"].format(channel=kanal.mention), ephemeral=True - ) - - -@tree.command(name="denychannel", description=S.CMD["denychannel"]) -@app_commands.guild_only() -@app_commands.describe(kanal=S.OPT["denychannel_kanal"]) -@app_commands.default_permissions(manage_guild=True) -async def cmd_denychannel(interaction: discord.Interaction, kanal: discord.TextChannel): - allowed = _get_allowed_channels() - if kanal.id not in allowed: - await interaction.response.send_message( - S.CHANNEL_UI["not_in_list"].format(channel=kanal.mention), ephemeral=True - ) - return - allowed.remove(kanal.id) - _set_allowed_channels(allowed) - log.info("DENYCHANNEL -%s by %s", kanal, interaction.user) - if allowed: - await interaction.response.send_message( - S.CHANNEL_UI["removed"].format(channel=kanal.mention), ephemeral=True - ) - else: - await interaction.response.send_message( - S.CHANNEL_UI["removed_last"].format(channel=kanal.mention), ephemeral=True - ) - - -@tree.command(name="channels", description=S.CMD["channels"]) -@app_commands.guild_only() -@app_commands.default_permissions(manage_guild=True) -async def cmd_channels(interaction: discord.Interaction): - allowed = _get_allowed_channels() - if not allowed: - desc = S.CHANNEL_UI["list_empty"] - else: - lines = "\n".join(f"\u2022 <#{cid}>" for cid in allowed) - desc = S.CHANNEL_UI["list_filled"].format(lines=lines) - embed = discord.Embed(title=S.CHANNEL_UI["list_title"], description=desc, color=0x5865F2) - await interaction.response.send_message(embed=embed, ephemeral=True) - - # --------------------------------------------------------------------------- # /fish - fishing minigame # --------------------------------------------------------------------------- @@ -4472,240 +3734,6 @@ async def cmd_fishsell(interaction: discord.Interaction): await interaction.followup.send(embed=embed, view=sell_view, ephemeral=True) -# --------------------------------------------------------------------------- -# /prestige /prestigeshop /prestigebuy -# --------------------------------------------------------------------------- -class PrestigeView(discord.ui.View): - def __init__(self, user_id: int, tab: str = "status"): - super().__init__(timeout=60) - self.user_id = user_id - self.tab = tab - - async def _rebuild(self, data: dict): - self.clear_items() - pp = data.get("prestige_points", 0) - upgrades: dict = data.get("prestige_upgrades") or {} - exp = data.get("exp", 0) - level = economy.get_level(exp) - - # Tab switcher buttons (row 0) - for tab_id, label in (("status", S.PRESTIGE_UI["btn_tab_status"]), ("shop", S.PRESTIGE_UI["btn_tab_shop"])): - btn = discord.ui.Button( - label=label, - style=discord.ButtonStyle.primary if tab_id == self.tab else discord.ButtonStyle.secondary, - disabled=(tab_id == self.tab), - row=0, - ) - btn.callback = self._switch_tab(tab_id) - self.add_item(btn) - - if self.tab == "status" and level >= economy.PRESTIGE_MIN_LEVEL: - confirm_btn = discord.ui.Button( - label=S.PRESTIGE_UI["btn_confirm"], - style=discord.ButtonStyle.danger, - row=1, - ) - confirm_btn.callback = self._do_prestige() - cancel_btn = discord.ui.Button( - label=S.PRESTIGE_UI["btn_cancel"], - style=discord.ButtonStyle.secondary, - row=1, - ) - cancel_btn.callback = self._do_cancel() - self.add_item(confirm_btn) - self.add_item(cancel_btn) - - elif self.tab == "shop": - for uid, item in economy.PRESTIGE_SHOP.items(): - cur_level = upgrades.get(uid, 0) - if cur_level >= item["max_level"]: - continue - cost = item["pp_cost"] - btn = discord.ui.Button( - label=S.PRESTIGE_UI["btn_buy_upgrade"].format(emoji=item["emoji"], name=S.PRESTIGE_SHOP_NAMES[uid], cost=cost), - style=discord.ButtonStyle.success if pp >= cost else discord.ButtonStyle.secondary, - disabled=(pp < cost), - row=1, - ) - btn.callback = self._buy_upgrade(uid) - self.add_item(btn) - - def _build_status_embed(self, data: dict) -> discord.Embed: - exp = data.get("exp", 0) - level = economy.get_level(exp) - pp = data.get("prestige_points", 0) - p_level = data.get("prestige_level", 0) - if level >= economy.PRESTIGE_MIN_LEVEL: - pp_preview = max(1, exp // 1000) - embed = discord.Embed( - title=S.TITLE["prestige_confirm"], - description=S.PRESTIGE_UI["confirm_desc"].format(level=level, exp=exp, pp=pp_preview), - color=0xF4C430, - ) - else: - embed = discord.Embed( - title=S.TITLE["prestige_too_low"], - description=S.PRESTIGE_UI["too_low_desc"].format(level=level, required=economy.PRESTIGE_MIN_LEVEL), - color=0xED4245, - ) - if p_level > 0: - embed.set_footer(text=S.PRESTIGE_UI["status_footer"].format(level=p_level, pp=pp)) - return embed - - def _build_shop_embed(self, data: dict) -> discord.Embed: - pp = data.get("prestige_points", 0) - upgrades: dict = data.get("prestige_upgrades") or {} - embed = discord.Embed( - title=S.TITLE["prestige_shop"], - description=S.PRESTIGE_UI["shop_desc"].format(pp=pp), - color=0xF4C430, - ) - for uid, item in economy.PRESTIGE_SHOP.items(): - cur_level = upgrades.get(uid, 0) - name = f"{item['emoji']} {S.PRESTIGE_SHOP_NAMES[uid]}" - cost_str = S.PRESTIGE_UI["shop_maxed"] if cur_level >= item["max_level"] else S.PRESTIGE_UI["shop_cost_fmt"].format(cost=item["pp_cost"]) - level_str = S.PRESTIGE_UI["shop_level_fmt"].format(cur=cur_level, max=item["max_level"]) - embed.add_field(name=f"{name} · {level_str} · {cost_str}", value=S.PRESTIGE_SHOP_DESCRIPTIONS[uid], inline=False) - return embed - - def _switch_tab(self, tab_id: str): - async def _cb(interaction: discord.Interaction): - if interaction.user.id != self.user_id: - await interaction.response.send_message(S.ERR["not_your_menu"], ephemeral=True) - return - self.tab = tab_id - await interaction.response.defer() - data = await economy.get_user(self.user_id) - await self._rebuild(data) - embed = self._build_status_embed(data) if tab_id == "status" else self._build_shop_embed(data) - await interaction.edit_original_response(embed=embed, view=self) - return _cb - - def _do_prestige(self): - async def _cb(interaction: discord.Interaction): - if interaction.user.id != self.user_id: - await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) - return - await interaction.response.defer() - res = await economy.do_prestige(self.user_id) - self.clear_items() - if not res["ok"]: - embed = discord.Embed( - title=S.TITLE["prestige_too_low"], - description=S.PRESTIGE_UI["too_low_desc"].format(level=res.get("level", 0), required=res.get("required", 30)), - color=0xED4245, - ) - else: - embed = discord.Embed( - title=S.TITLE["prestige_success"].format(level=res["prestige_level"]), - description=S.PRESTIGE_UI["success_desc"].format(pp=res["pp_earned"], level=res["prestige_level"], total_pp=res["prestige_points"]), - color=0xF4C430, - ) - await interaction.edit_original_response(embed=embed, view=self) - if res.get("ok") and interaction.guild: - member = interaction.guild.get_member(self.user_id) - if member: - asyncio.create_task(_ensure_level_role(member, 1)) - return _cb - - def _do_cancel(self): - async def _cb(interaction: discord.Interaction): - if interaction.user.id != self.user_id: - await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) - return - self.clear_items() - await interaction.response.edit_message(view=self) - return _cb - - def _buy_upgrade(self, upgrade_id: str): - async def _cb(interaction: discord.Interaction): - if interaction.user.id != self.user_id: - await interaction.response.send_message(S.ERR["not_your_menu"], ephemeral=True) - return - await interaction.response.defer() - res = await economy.do_prestige_buy(self.user_id, upgrade_id) - if not res["ok"]: - if res["reason"] == "insufficient_pp": - err = S.PRESTIGE_UI["buy_no_pp"].format(have=res["have"], need=res["need"]) - elif res["reason"] == "maxed": - err = S.PRESTIGE_UI["buy_maxed"] - else: - err = S.ERR["generic_error"].format(error=res["reason"]) - await interaction.followup.send(err, ephemeral=True) - return - data = await economy.get_user(self.user_id) - await self._rebuild(data) - embed = self._build_shop_embed(data) - item = economy.PRESTIGE_SHOP[res["upgrade_id"]] - name = f"{item['emoji']} {S.PRESTIGE_SHOP_NAMES[res['upgrade_id']]}" - embed.description = S.PRESTIGE_UI["buy_success_desc"].format( - name=name, new_level=res["new_level"], max_level=item["max_level"], pp=res["pp_remaining"] - ) + "\n\n" + (embed.description or "") - await interaction.edit_original_response(embed=embed, view=self) - return _cb - - async def on_timeout(self): - self.clear_items() - - -@tree.command(name="prestige", description=S.CMD["prestige"]) -@app_commands.guild_only() -async def cmd_prestige(interaction: discord.Interaction): - if await _check_cmd_rate(interaction): - return - data = await economy.get_user(interaction.user.id) - if data.get("eco_banned"): - await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) - return - view = PrestigeView(interaction.user.id) - await view._rebuild(data) - embed = view._build_status_embed(data) - await interaction.response.send_message(embed=embed, view=view, ephemeral=True) - - -@tree.command(name="prestigeshop", description=S.CMD["prestigeshop"]) -async def cmd_prestigeshop(interaction: discord.Interaction): - data = await economy.get_user(interaction.user.id) - view = PrestigeView(interaction.user.id, tab="shop") - await view._rebuild(data) - embed = view._build_shop_embed(data) - await interaction.response.send_message(embed=embed, view=view, ephemeral=True) - - -@tree.command(name="prestigebuy", description=S.CMD["prestigebuy"]) -@app_commands.describe(upgrade=S.OPT["prestigebuy_upgrade"]) -async def cmd_prestigebuy(interaction: discord.Interaction, upgrade: str): - if await _check_cmd_rate(interaction): - return - res = await economy.do_prestige_buy(interaction.user.id, upgrade.strip().lower()) - if not res["ok"]: - if res["reason"] == "banned": - await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) - elif res["reason"] == "not_found": - await interaction.response.send_message(S.PRESTIGE_UI["buy_not_found"], ephemeral=True) - elif res["reason"] == "maxed": - await interaction.response.send_message(S.PRESTIGE_UI["buy_maxed"], ephemeral=True) - elif res["reason"] == "insufficient_pp": - await interaction.response.send_message( - S.PRESTIGE_UI["buy_no_pp"].format(have=res["have"], need=res["need"]), ephemeral=True - ) - return - - item = economy.PRESTIGE_SHOP[res["upgrade_id"]] - name = f"{item['emoji']} {S.PRESTIGE_SHOP_NAMES[res['upgrade_id']]}" - embed = discord.Embed( - title=S.TITLE["prestige_buy_ok"], - description=S.PRESTIGE_UI["buy_success_desc"].format( - name=name, - new_level=res["new_level"], - max_level=res["max_level"], - pp=res["pp_remaining"], - ), - color=0x57F287, - ) - await interaction.response.send_message(embed=embed, ephemeral=True) - - # --------------------------------------------------------------------------- # Error handling for slash commands # --------------------------------------------------------------------------- @@ -4754,7 +3782,11 @@ def _asyncio_exception_handler(loop: asyncio.AbstractEventLoop, context: dict) - if __name__ == "__main__": if not config.DISCORD_TOKEN: - raise SystemExit("DISCORD_TOKEN pole seadistatud. Kopeeri .env.example failiks .env ja täida see.") + profile_key = "DISCORD_TOKEN_ECONOMY" if config.BOT_PROFILE == "economy" else "DISCORD_TOKEN_DEV" + raise SystemExit( + f"{profile_key} pole seadistatud profiilile '{config.BOT_PROFILE}'. " + "Kopeeri .env.example failiks .env ja täida see." + ) async def _main() -> None: loop = asyncio.get_event_loop() diff --git a/config.py b/config.py index f435bcb..b3d04bb 100644 --- a/config.py +++ b/config.py @@ -3,14 +3,58 @@ from dotenv import load_dotenv load_dotenv() -DISCORD_TOKEN = os.getenv("DISCORD_TOKEN") +BOT_PROFILE = os.getenv("BOT_PROFILE", "dev").strip().lower() or "dev" +if BOT_PROFILE not in {"dev", "economy"}: + raise SystemExit("BOT_PROFILE must be either 'dev' or 'economy'.") + + +def _env_int(name: str, default: int) -> int: + raw = os.getenv(name) + if raw is None or not raw.strip(): + return default + return int(raw) + + +_LEGACY_DISCORD_TOKEN = os.getenv("DISCORD_TOKEN", "") +DISCORD_TOKEN_DEV = os.getenv("DISCORD_TOKEN_DEV", "") +DISCORD_TOKEN_ECONOMY = os.getenv("DISCORD_TOKEN_ECONOMY", "") +DISCORD_TOKEN = ( + DISCORD_TOKEN_ECONOMY if BOT_PROFILE == "economy" else DISCORD_TOKEN_DEV +) or _LEGACY_DISCORD_TOKEN + SHEET_ID = os.getenv("SHEET_ID") GOOGLE_CREDS_PATH = os.getenv("GOOGLE_CREDS_PATH", "credentials.json") -GUILD_ID = int(os.getenv("GUILD_ID", "0")) -BIRTHDAY_CHANNEL_ID = int(os.getenv("BIRTHDAY_CHANNEL_ID", "0")) + +_LEGACY_GUILD_ID = _env_int("GUILD_ID", 0) +GUILD_ID_DEV = _env_int("GUILD_ID_DEV", _LEGACY_GUILD_ID) +GUILD_ID_ECONOMY = _env_int("GUILD_ID_ECONOMY", _LEGACY_GUILD_ID) +GUILD_ID = GUILD_ID_ECONOMY if BOT_PROFILE == "economy" else GUILD_ID_DEV + +_LEGACY_BIRTHDAY_CHANNEL_ID = _env_int("BIRTHDAY_CHANNEL_ID", 0) +BIRTHDAY_CHANNEL_ID_DEV = _env_int("BIRTHDAY_CHANNEL_ID_DEV", _LEGACY_BIRTHDAY_CHANNEL_ID) +BIRTHDAY_CHANNEL_ID_ECONOMY = _env_int("BIRTHDAY_CHANNEL_ID_ECONOMY", 0) +BIRTHDAY_CHANNEL_ID = ( + BIRTHDAY_CHANNEL_ID_ECONOMY + if BOT_PROFILE == "economy" + else BIRTHDAY_CHANNEL_ID_DEV +) + BIRTHDAY_WINDOW_DAYS = int(os.getenv("BIRTHDAY_WINDOW_DAYS", "7")) BASE_ROLE_IDS: list[int] = [1478304631930228779, 1478302278862766190] PB_URL = os.getenv("PB_URL", "http://127.0.0.1:8090") PB_ADMIN_EMAIL = os.getenv("PB_ADMIN_EMAIL", "") PB_ADMIN_PASSWORD = os.getenv("PB_ADMIN_PASSWORD", "") + +_LEGACY_PB_COLLECTION = os.getenv("PB_ECONOMY_COLLECTION", "").strip() +PB_ECONOMY_COLLECTION_DEV = ( + os.getenv("PB_ECONOMY_COLLECTION_DEV", "").strip() + or (_LEGACY_PB_COLLECTION if _LEGACY_PB_COLLECTION else "economy_users_dev") +) +PB_ECONOMY_COLLECTION_ECONOMY = ( + os.getenv("PB_ECONOMY_COLLECTION_ECONOMY", "").strip() + or (_LEGACY_PB_COLLECTION if _LEGACY_PB_COLLECTION else "economy_users_prod") +) +PB_ECONOMY_COLLECTION = ( + PB_ECONOMY_COLLECTION_ECONOMY if BOT_PROFILE == "economy" else PB_ECONOMY_COLLECTION_DEV +) diff --git a/dev_member_commands.py b/dev_member_commands.py new file mode 100644 index 0000000..6234286 --- /dev/null +++ b/dev_member_commands.py @@ -0,0 +1,314 @@ +from __future__ import annotations + +import datetime +import logging +from typing import Callable + +import discord +from discord import app_commands + +import sheets +import strings as S +from member_sync import announce_birthday, sync_member + + +class BirthdayPages(discord.ui.View): + def __init__(self, pages: list[discord.Embed], start: int = 0): + super().__init__(timeout=120) + self.pages = pages + self.current = start + self._update_buttons() + + def _update_buttons(self): + self.prev_button.disabled = self.current == 0 + self.next_button.disabled = self.current >= len(self.pages) - 1 + + @discord.ui.button(label="◀", style=discord.ButtonStyle.secondary) + async def prev_button(self, interaction: discord.Interaction, button: discord.ui.Button): + self.current -= 1 + self._update_buttons() + await interaction.response.edit_message(embed=self.pages[self.current], view=self) + + @discord.ui.button(label="▶", style=discord.ButtonStyle.secondary) + async def next_button(self, interaction: discord.Interaction, button: discord.ui.Button): + self.current += 1 + self._update_buttons() + await interaction.response.edit_message(embed=self.pages[self.current], view=self) + + +def _build_birthday_pages( + guild: discord.Guild | None = None, +) -> tuple[list[discord.Embed], int]: + """Build 12 monthly embeds (one per calendar month). + + Returns (pages, start_index) where start_index is the current month. + """ + rows = sheets.get_cache() + today = datetime.date.today() + + by_month: dict[int, list[tuple[int, str, int | None]]] = {m: [] for m in range(1, 13)} + + for row in rows: + name = str(row.get("Nimi", "")).strip() + bday_str = str(row.get("Sünnipäev", "")).strip() + if not name or not bday_str or bday_str.strip().lower() in ("-", "x", "n/a", "none", "ei"): + continue + bday = None + for fmt in ["%d/%m/%Y", "%Y-%m-%d", "%m-%d"]: + try: + bday = datetime.datetime.strptime(bday_str, fmt).date() + break + except ValueError: + continue + if bday is None: + continue + raw_uid = str(row.get("User ID", "")).strip() + try: + uid = int(raw_uid) if raw_uid and raw_uid not in ("0", "-") else None + except ValueError: + uid = None + by_month[bday.month].append((bday.day, name, uid)) + + pages: list[discord.Embed] = [] + for month in range(1, 13): + entries = sorted(by_month[month], key=lambda x: x[0]) + embed = discord.Embed( + title=f"🎂 {S.BIRTHDAY_MONTHS[month - 1]}", + color=0xF4A261, + ) + if not entries: + embed.description = S.BIRTHDAY_UI["no_entries"] + else: + lines = [] + for day, name, uid in entries: + try: + this_year = datetime.date(today.year, month, day) + except ValueError: + this_year = datetime.date(today.year, month, day - 1) + next_bday = this_year if this_year >= today else this_year.replace(year=today.year + 1) + days_until = (next_bday - today).days + if days_until == 0: + when = S.BIRTHDAY_UI["today"] + elif days_until == 1: + when = S.BIRTHDAY_UI["tomorrow"] + else: + when = S.BIRTHDAY_UI["in_days"].format(days=days_until) + display = name + if guild and uid: + member = guild.get_member(uid) + if member: + display = member.mention + lines.append(f"{display} - {day:02d}/{month:02d} · {when}") + embed.description = "\n".join(lines) + embed.set_footer(text=S.BIRTHDAY_UI["footer"].format(month=month, month_name=S.BIRTHDAY_MONTHS[month - 1])) + pages.append(embed) + + return pages, today.month - 1 + + +def _sheet_stats(rows: list[dict]) -> str: + """Return a formatted string with sheet completeness statistics.""" + total = len(rows) + missing_uid = [] + missing_discord = [] + missing_birthday = [] + + for row in rows: + name = str(row.get("Nimi", "")).strip() or S.CHECK_UI["no_name"] + uid = str(row.get("User ID", "")).strip() + discord_name = str(row.get("Discord", "")).strip() + bday = str(row.get("Sünnipäev", "")).strip() + + if not uid or uid == "0": + missing_uid.append(name) + if not discord_name: + missing_discord.append(name) + if not bday: + missing_birthday.append(name) + + lines = [S.CHECK_UI["sheet_stats_header"].format(total=total)] + lines.append("") + + def stat_line(label: str, missing: list[str]) -> str: + count = len(missing) + if count == 0: + return S.CHECK_UI["stat_ok"].format(label=label) + names = ", ".join(missing[:5]) + more = S.CHECK_UI["stat_more"].format(count=count - 5) if count > 5 else "" + return S.CHECK_UI["stat_warn"].format(label=label, count=count, names=names, more=more) + + lines.append(stat_line(S.CHECK_UI["stat_uid"], missing_uid)) + lines.append(stat_line(S.CHECK_UI["stat_discord"], missing_discord)) + lines.append(stat_line(S.CHECK_UI["stat_bday"], missing_birthday)) + + return "\n".join(lines) + + +def register_dev_member_commands( + tree: app_commands.CommandTree, + bot: discord.Client, + log: logging.Logger, + has_announced_today: Callable[[int], bool], + mark_announced_today: Callable[[int], None], +) -> None: + @tree.command(name="birthdays", description=S.CMD["birthdays"]) + @app_commands.guild_only() + async def cmd_birthdays(interaction: discord.Interaction): + await interaction.response.defer() + + try: + sheets.refresh() + except Exception as e: + await interaction.followup.send(S.ERR["sheet_error"].format(error=e), ephemeral=True) + return + + pages, start = _build_birthday_pages(guild=interaction.guild) + await interaction.followup.send(embed=pages[start], view=BirthdayPages(pages, start=start)) + + @tree.command(name="check", description=S.CMD["check"]) + @app_commands.guild_only() + @app_commands.default_permissions(manage_roles=True) + async def cmd_check(interaction: discord.Interaction): + await interaction.response.defer(ephemeral=True) + + guild = interaction.guild + if guild is None: + await interaction.followup.send(S.ERR["guild_only"], ephemeral=True) + return + + try: + data = sheets.refresh() + except Exception as e: + await interaction.followup.send(S.ERR["sheet_error"].format(error=e), ephemeral=True) + return + + ids_filled = 0 + for row in data: + uid = str(row.get("User ID", "")).strip() + if uid and uid not in ("0", "-"): + continue + discord_name = str(row.get("Discord", "")).strip() + if not discord_name: + continue + guild_member = discord.utils.find( + lambda m, n=discord_name: m.name.lower() == n.lower(), + guild.members, + ) + if guild_member: + sheets.set_user_id(discord_name, guild_member.id) + ids_filled += 1 + + data = sheets.get_cache() + + changed_count = 0 + not_found = 0 + already_ok = 0 + errors_total = 0 + birthday_pings = 0 + details: list[str] = [] + sync_updates: list[tuple[int, bool]] = [] + + for member in guild.members: + if member.bot: + continue + + result = await sync_member(member, guild) + + if result.not_found: + not_found += 1 + continue + + sync_updates.append((member.id, result.synced)) + + if result.errors: + errors_total += len(result.errors) + for err in result.errors: + details.append(S.CHECK_UI["detail_error"].format(error=err)) + + if result.changed: + changed_count += 1 + parts = [] + if result.nickname_changed: + parts.append(S.CHECK_UI["detail_nickname"]) + if result.roles_added: + parts.append(S.CHECK_UI["detail_roles_added"].format(roles=", ".join(result.roles_added))) + details.append(S.CHECK_UI["detail_changed"].format(name=member.display_name, parts=", ".join(parts))) + else: + already_ok += 1 + + if result.birthday_soon and not has_announced_today(member.id): + birthday_pings += 1 + await announce_birthday(member, bot) + mark_announced_today(member.id) + + if sync_updates: + try: + sheets.batch_set_synced(sync_updates) + except Exception as e: + log.error("/check batch_set_synced failed: %s", e) + + summary_lines = [ + S.CHECK_UI["done"], + S.CHECK_UI["already_ok"].format(count=already_ok), + S.CHECK_UI["fixed"].format(count=changed_count), + S.CHECK_UI["not_found"].format(count=not_found), + S.CHECK_UI["bday_pings"].format(count=birthday_pings), + ] + if errors_total: + summary_lines.append(S.CHECK_UI["errors"].format(count=errors_total)) + + summary = "\n".join(summary_lines) + + if details: + detail_text = "\n".join(details[:20]) + summary += f"\n\n{S.CHECK_UI['details_header']}\n{detail_text}" + if len(details) > 20: + summary += "\n" + S.CHECK_UI["details_more"].format(count=len(details) - 20) + + stats = _sheet_stats(data) + id_note = S.CHECK_UI["ids_filled"].format(count=ids_filled) if ids_filled else "" + summary = id_note + "\n" + summary + "\n\n" + stats + + await interaction.followup.send(summary.strip(), ephemeral=True) + log.info( + "/check - OK=%d, Fixed=%d, NotFound=%d, IDs=%d, Errors=%d", + already_ok, + changed_count, + not_found, + ids_filled, + errors_total, + ) + + @tree.command(name="member", description=S.CMD["member"]) + @app_commands.guild_only() + @app_commands.default_permissions(manage_roles=True) + async def cmd_member(interaction: discord.Interaction, user: discord.Member): + row = sheets.find_member(user.id, user.name) + if row is None: + await interaction.response.send_message( + S.ERR["member_not_found"].format(name=user.display_name), + ephemeral=True, + ) + return + + embed = discord.Embed(title=S.MEMBER_UI["title"].format(name=user.display_name), color=user.color) + + bday_str = str(row.get("Sünnipäev", "")).strip() + if bday_str and bday_str.lower() not in ("-", "x", "n/a", "none", "ei"): + for fmt in ["%d/%m/%Y", "%Y-%m-%d"]: + try: + bday = datetime.datetime.strptime(bday_str, fmt).date() + if 1920 <= bday.year <= datetime.date.today().year: + today = datetime.date.today() + age = today.year - bday.year - ((today.month, today.day) < (bday.month, bday.day)) + embed.add_field(name=S.MEMBER_UI["age_field"], value=S.MEMBER_UI["age_val"].format(age=age), inline=True) + break + except ValueError: + continue + + for sheet_key, label in S.MEMBER_FIELDS: + val = str(row.get(sheet_key, "")).strip() + if val: + embed.add_field(name=label, value=val, inline=True) + + await interaction.response.send_message(embed=embed, ephemeral=True) diff --git a/dev_member_runtime.py b/dev_member_runtime.py new file mode 100644 index 0000000..a7cb70c --- /dev/null +++ b/dev_member_runtime.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import logging +from collections.abc import Callable + +import discord + +import config +import sheets +from member_sync import announce_birthday, is_birthday_today, sync_member + + +async def run_birthday_daily( + bot: discord.Client, + log: logging.Logger, + has_announced_today: Callable[[int], bool], + mark_announced_today: Callable[[int], None], +) -> None: + """Announce birthdays in the configured guild for users whose birthday is today.""" + guild = bot.get_guild(config.GUILD_ID) + if guild is None: + log.warning("Birthday task: guild %s not found", config.GUILD_ID) + return + + try: + data = sheets.refresh() + except Exception as e: + log.error("Birthday task: sheet refresh failed: %s", e) + data = sheets.get_cache() + + announced = 0 + for row in data: + bday_str = str(row.get("Sünnipäev", "")).strip() + if not is_birthday_today(bday_str): + continue + + member = None + raw_id = str(row.get("User ID", "")).strip() + if raw_id: + try: + member = guild.get_member(int(raw_id)) + except ValueError: + pass + if member is None: + discord_name = str(row.get("Discord", "")).strip() + if discord_name: + member = discord.utils.find( + lambda m, n=discord_name: m.name.lower() == n.lower(), + guild.members, + ) + if member and not has_announced_today(member.id): + await announce_birthday(member, bot) + mark_announced_today(member.id) + announced += 1 + + log.info("Sünnipäeva ülesanne: teavitati %d liiget", announced) + + +async def handle_member_join( + member: discord.Member, + bot: discord.Client, + log: logging.Logger, + has_announced_today: Callable[[int], bool], + mark_announced_today: Callable[[int], None], + log_sync_result: Callable[[discord.Member, object], None], +) -> None: + """Sync a newly joined member against sheet data and trigger birthday notice if needed.""" + log.info("Member joined: %s (ID: %s)", member, member.id) + + if not sheets.get_cache(): + sheets.refresh() + + result = await sync_member(member, member.guild) + + if result.not_found: + try: + sheets.add_new_member_row(member.name, member.id) + log.info( + " → %s not in sheet, added new row (Discord=%s, ID=%s)", + member, + member.name, + member.id, + ) + except Exception as e: + log.error(" → Failed to add sheet row for %s: %s", member, e) + return + + log_sync_result(member, result) + sheets.set_synced(member.id, result.synced) + + if result.birthday_soon and not has_announced_today(member.id): + await announce_birthday(member, bot) + mark_announced_today(member.id) diff --git a/docs/POCKETBASE_SETUP.md b/docs/POCKETBASE_SETUP.md index 52de860..f4ed270 100644 --- a/docs/POCKETBASE_SETUP.md +++ b/docs/POCKETBASE_SETUP.md @@ -13,9 +13,14 @@ Create your admin account on first launch via the Admin UI. --- -## 2. Create the `economy_users` collection +## 2. Create economy collections for both bot profiles -In the Admin UI → **Collections** → **New collection** → name it exactly `economy_users`. +In the Admin UI → **Collections** → **New collection** and create: + +- `economy_users_dev` +- `economy_users_prod` + +Use the same schema for both collections. Add the following fields: @@ -51,6 +56,8 @@ Add to your `.env`: PB_URL=http://127.0.0.1:8090 PB_ADMIN_EMAIL=your-admin@email.com PB_ADMIN_PASSWORD=your-admin-password +PB_ECONOMY_COLLECTION_DEV=economy_users_dev +PB_ECONOMY_COLLECTION_ECONOMY=economy_users_prod ``` --- diff --git a/economy_admin_commands.py b/economy_admin_commands.py new file mode 100644 index 0000000..d2304ba --- /dev/null +++ b/economy_admin_commands.py @@ -0,0 +1,310 @@ +from __future__ import annotations + +import datetime +import logging +from collections.abc import Awaitable, Callable + +import discord +from discord import app_commands + +import economy +import strings as S + + +async def _dm_user(bot: discord.Client, user_id: int, msg: str) -> None: + """Best-effort DM to a user.""" + try: + user = bot.get_user(user_id) or await bot.fetch_user(user_id) + await user.send(msg) + except Exception: + pass + + +def register_economy_admin_commands( + tree: app_commands.CommandTree, + bot: discord.Client, + log: logging.Logger, + cd_ts: Callable[[datetime.timedelta], str], + apply_level_role: Callable[[discord.Member, int, int], Awaitable[None]], +) -> None: + @tree.command(name="adminseason", description=S.CMD["adminseason"]) + @app_commands.guild_only() + @app_commands.default_permissions(manage_guild=True) + @app_commands.describe(top_n=S.OPT["adminseason_top_n"]) + async def cmd_adminseason(interaction: discord.Interaction, top_n: int = 10): + await interaction.response.defer(ephemeral=True) + top = await economy.do_season_reset(top_n) + guild = interaction.guild + + if guild: + all_role_names = {name for _, name in economy.LEVEL_ROLES} + for role_name in all_role_names: + role = discord.utils.find(lambda r: r.name == role_name, guild.roles) + if not role: + continue + for member in list(role.members): + try: + await member.remove_roles(role, reason="Season reset") + except discord.Forbidden: + pass + + medals = ["🥇", "🥈", "🥉"] + lines = [] + for i, (uid, exp, lvl) in enumerate(top): + member = guild.get_member(int(uid)) if guild else None + name = member.display_name if member else S.LEADERBOARD_UI["unknown_user"].format(uid=uid) + prefix = medals[i] if i < 3 else f"**{i + 1}.**" + lines.append(S.SEASON["entry"].format(prefix=prefix, name=name, exp=exp, level=lvl)) + + embed = discord.Embed( + title=S.TITLE["adminseason"], + description=(S.SEASON["top_header"] + "\n" + "\n".join(lines)) if lines else S.SEASON["no_players"], + color=0xF4C430, + ) + embed.set_footer(text=S.SEASON["footer"]) + await interaction.followup.send(embed=embed, ephemeral=False) + await interaction.followup.send(S.SEASON["done"], ephemeral=True) + log.info("/adminseason triggered by %s (top_n=%d)", interaction.user, top_n) + + @tree.command(name="admincoins", description=S.CMD["admincoins"]) + @app_commands.guild_only() + @app_commands.describe( + kasutaja=S.OPT["admin_kasutaja"], + kogus=S.OPT["admincoins_kogus"], + põhjus=S.OPT["admin_põhjus"], + ) + @app_commands.default_permissions(manage_guild=True) + async def cmd_admincoins(interaction: discord.Interaction, kasutaja: discord.Member, kogus: int, põhjus: str): + if kogus == 0: + await interaction.response.send_message(S.ERR["admincoins_zero"], ephemeral=True) + return + res = await economy.do_admin_coins(kasutaja.id, kogus, interaction.user.id, põhjus) + verb = f"+{kogus}" if kogus > 0 else str(kogus) + emoji = "💰" if kogus > 0 else "💸" + await interaction.response.send_message( + S.ADMIN["coins_done"].format( + emoji=emoji, + name=kasutaja.display_name, + verb=verb, + coin=economy.COIN, + balance=f"{res['balance']:,}", + reason=põhjus, + ), + ephemeral=True, + ) + await _dm_user( + bot, + kasutaja.id, + S.ADMIN["coins_dm"].format( + emoji=emoji, + verb=verb, + coin=economy.COIN, + reason=põhjus, + balance=f"{res['balance']:,}", + ), + ) + log.info("ADMINCOINS %s → %s (%s) by %s", verb, kasutaja, põhjus, interaction.user) + + @tree.command(name="adminjail", description=S.CMD["adminjail"]) + @app_commands.guild_only() + @app_commands.describe( + kasutaja=S.OPT["admin_kasutaja"], + minutid=S.OPT["adminjail_minutid"], + põhjus=S.OPT["admin_põhjus"], + ) + @app_commands.default_permissions(manage_guild=True) + async def cmd_adminjail(interaction: discord.Interaction, kasutaja: discord.Member, minutid: int, põhjus: str): + if minutid <= 0: + await interaction.response.send_message(S.ERR["positive_duration"], ephemeral=True) + return + await economy.do_admin_jail(kasutaja.id, minutid, interaction.user.id, põhjus) + until_ts = cd_ts(datetime.timedelta(minutes=minutid)) + await interaction.response.send_message( + S.ADMIN["jail_done"].format(name=kasutaja.display_name, minutes=minutid, ts=until_ts, reason=põhjus), + ephemeral=True, + ) + await _dm_user( + bot, + kasutaja.id, + S.ADMIN["jail_dm"].format(minutes=minutid, reason=põhjus, ts=until_ts), + ) + log.info("ADMINJAIL %s %dmin (%s) by %s", kasutaja, minutid, põhjus, interaction.user) + + @tree.command(name="adminunjail", description=S.CMD["adminunjail"]) + @app_commands.guild_only() + @app_commands.describe(kasutaja=S.OPT["admin_kasutaja"]) + @app_commands.default_permissions(manage_guild=True) + async def cmd_adminunjail(interaction: discord.Interaction, kasutaja: discord.Member): + await economy.do_admin_unjail(kasutaja.id, interaction.user.id) + await interaction.response.send_message( + S.ADMIN["unjail_done"].format(name=kasutaja.display_name), ephemeral=True + ) + await _dm_user(bot, kasutaja.id, S.ADMIN["unjail_dm"]) + log.info("ADMINUNJAIL %s by %s", kasutaja, interaction.user) + + @tree.command(name="adminban", description=S.CMD["adminban"]) + @app_commands.guild_only() + @app_commands.describe( + kasutaja=S.OPT["admin_kasutaja"], + põhjus=S.OPT["admin_põhjus"], + ) + @app_commands.default_permissions(manage_guild=True) + async def cmd_adminban(interaction: discord.Interaction, kasutaja: discord.Member, põhjus: str): + if bot.user and kasutaja.id == bot.user.id: + await interaction.response.send_message(S.ERR["admin_ban_bot"], ephemeral=True) + return + await economy.do_admin_ban(kasutaja.id, interaction.user.id, põhjus) + await interaction.response.send_message( + S.ADMIN["ban_done"].format(name=kasutaja.display_name, reason=põhjus), + ephemeral=True, + ) + await _dm_user(bot, kasutaja.id, S.ADMIN["ban_dm"].format(reason=põhjus)) + log.info("ADMINBAN %s (%s) by %s", kasutaja, põhjus, interaction.user) + + @tree.command(name="adminunban", description=S.CMD["adminunban"]) + @app_commands.guild_only() + @app_commands.describe(kasutaja=S.OPT["admin_kasutaja"]) + @app_commands.default_permissions(manage_guild=True) + async def cmd_adminunban(interaction: discord.Interaction, kasutaja: discord.Member): + await economy.do_admin_unban(kasutaja.id, interaction.user.id) + await interaction.response.send_message( + S.ADMIN["unban_done"].format(name=kasutaja.display_name), ephemeral=True + ) + await _dm_user(bot, kasutaja.id, S.ADMIN["unban_dm"]) + log.info("ADMINUNBAN %s by %s", kasutaja, interaction.user) + + @tree.command(name="adminreset", description=S.CMD["adminreset"]) + @app_commands.guild_only() + @app_commands.describe( + kasutaja=S.OPT["admin_kasutaja"], + põhjus=S.OPT["admin_põhjus"], + ) + @app_commands.default_permissions(manage_guild=True) + async def cmd_adminreset(interaction: discord.Interaction, kasutaja: discord.Member, põhjus: str): + if bot.user and kasutaja.id == bot.user.id: + await interaction.response.send_message(S.ERR["admin_reset_bot"], ephemeral=True) + return + await economy.do_admin_reset(kasutaja.id, interaction.user.id) + await interaction.response.send_message( + S.ADMIN["reset_done"].format(name=kasutaja.display_name, reason=põhjus), + ephemeral=True, + ) + await _dm_user(bot, kasutaja.id, S.ADMIN["reset_dm"].format(reason=põhjus)) + log.info("ADMINRESET %s (%s) by %s", kasutaja, põhjus, interaction.user) + + @tree.command(name="adminview", description=S.CMD["adminview"]) + @app_commands.guild_only() + @app_commands.describe(kasutaja=S.OPT["admin_kasutaja"]) + @app_commands.default_permissions(manage_guild=True) + async def cmd_adminview(interaction: discord.Interaction, kasutaja: discord.Member): + res = await economy.do_admin_inspect(kasutaja.id) + data = res["data"] + items_str = ", ".join(data.get("items", [])) or "-" + uses = data.get("item_uses", {}) + uses_str = ", ".join(f"{k}:{v}" for k, v in uses.items()) if uses else "-" + jailed = data.get("jailed_until") or "-" + banned = S.ADMINVIEW_UI["banned_yes"] if data.get("eco_banned") else S.ADMINVIEW_UI["banned_no"] + exp = data.get("exp", 0) + level = economy.get_level(exp) + prestige_lvl = data.get("prestige_level", 0) + prestige_pp = data.get("prestige_points", 0) + total_fish = data.get("total_fish_caught", 0) + inv_fish = len(data.get("fish_inventory") or []) + embed = discord.Embed( + title=S.ADMINVIEW_UI["title"].format(name=kasutaja.display_name), + color=0x5865F2, + ) + embed.add_field(name=S.ADMINVIEW_UI["f_balance"], value=f"{data.get('balance', 0):,} {economy.COIN}", inline=True) + embed.add_field(name=S.ADMINVIEW_UI["f_exp"], value=S.ADMINVIEW_UI["exp_val"].format(exp=f"{exp:,}", level=level), inline=True) + embed.add_field(name=S.ADMINVIEW_UI["f_streak"], value=str(data.get("daily_streak", 0)), inline=True) + embed.add_field(name=S.ADMINVIEW_UI["f_banned"], value=banned, inline=True) + embed.add_field(name=S.ADMINVIEW_UI["f_jailed"], value=jailed, inline=True) + embed.add_field(name=S.ADMINVIEW_UI["f_prestige"], value=S.ADMINVIEW_UI["prestige_val"].format(level=prestige_lvl, pp=prestige_pp), inline=True) + embed.add_field(name=S.ADMINVIEW_UI["f_fish"], value=S.ADMINVIEW_UI["fish_val"].format(caught=total_fish, inv=inv_fish), inline=True) + embed.add_field(name=S.ADMINVIEW_UI["f_items"], value=items_str, inline=False) + embed.add_field(name=S.ADMINVIEW_UI["f_uses"], value=uses_str, inline=False) + embed.add_field(name=S.ADMINVIEW_UI["f_last_daily"], value=data.get("last_daily") or "-", inline=True) + embed.add_field(name=S.ADMINVIEW_UI["f_last_work"], value=data.get("last_work") or "-", inline=True) + embed.add_field(name=S.ADMINVIEW_UI["f_last_crime"], value=data.get("last_crime") or "-", inline=True) + embed.add_field(name=S.ADMINVIEW_UI["f_last_fish"], value=data.get("last_fish") or "-", inline=True) + embed.set_footer(text=S.ADMINVIEW_UI["footer"].format(uid=kasutaja.id)) + await interaction.response.send_message(embed=embed, ephemeral=True) + log.info("ADMINVIEW %s by %s", kasutaja, interaction.user) + + @tree.command(name="adminexp", description=S.CMD["adminexp"]) + @app_commands.guild_only() + @app_commands.describe( + kasutaja=S.OPT["admin_kasutaja"], + kogus=S.OPT["adminexp_kogus"], + põhjus=S.OPT["admin_põhjus"], + ) + @app_commands.default_permissions(manage_guild=True) + async def cmd_adminexp(interaction: discord.Interaction, kasutaja: discord.Member, kogus: int, põhjus: str): + if kogus == 0: + await interaction.response.send_message(S.ERR["admincoins_zero"], ephemeral=True) + return + res = await economy.do_admin_exp(kasutaja.id, kogus, interaction.user.id, põhjus) + verb = f"+{kogus}" if kogus > 0 else str(kogus) + emoji = "📈" if kogus > 0 else "📉" + await interaction.response.send_message( + S.ADMIN["exp_done"].format( + emoji=emoji, + name=kasutaja.display_name, + verb=verb, + exp=f"{res['exp']:,}", + level=res["new_level"], + reason=põhjus, + ), + ephemeral=True, + ) + await _dm_user( + bot, + kasutaja.id, + S.ADMIN["exp_dm"].format( + emoji=emoji, + verb=verb, + reason=põhjus, + exp=f"{res['exp']:,}", + level=res["new_level"], + ), + ) + if res["level_changed"]: + member = interaction.guild.get_member(kasutaja.id) if interaction.guild else None + if member: + await apply_level_role(member, res["new_level"], res["old_level"]) + log.info("ADMINEXP %s → %s (%s) by %s", verb, kasutaja, põhjus, interaction.user) + + @tree.command(name="adminitem", description=S.CMD["adminitem"]) + @app_commands.guild_only() + @app_commands.describe( + kasutaja=S.OPT["admin_kasutaja"], + ese=S.OPT["adminitem_ese"], + tegevus=S.OPT["adminitem_tegevus"], + ) + @app_commands.default_permissions(manage_guild=True) + async def cmd_adminitem(interaction: discord.Interaction, kasutaja: discord.Member, ese: str, tegevus: str): + action = tegevus.strip().lower() + if action not in ("anna", "eemalda"): + await interaction.response.send_message(S.ADMIN["item_invalid"].format(item_id=tegevus), ephemeral=True) + return + action_key = "give" if action == "anna" else "remove" + res = await economy.do_admin_item(kasutaja.id, ese, action_key, interaction.user.id) + if not res["ok"]: + if res["reason"] == "invalid_item": + await interaction.response.send_message(S.ADMIN["item_invalid"].format(item_id=ese), ephemeral=True) + elif res["reason"] == "not_owned": + await interaction.response.send_message( + S.ADMIN["item_not_owned"].format(name=kasutaja.display_name, item_id=ese), + ephemeral=True, + ) + else: + await interaction.response.send_message(S.ERR["generic_error"].format(error=res["reason"]), ephemeral=True) + return + item_name = economy.SHOP[ese]["name"] + if action_key == "give": + await interaction.response.send_message(S.ADMIN["item_given"].format(item=item_name, name=kasutaja.display_name), ephemeral=True) + await _dm_user(bot, kasutaja.id, S.ADMIN["item_dm_given"].format(item=item_name)) + else: + await interaction.response.send_message(S.ADMIN["item_removed"].format(item=item_name, name=kasutaja.display_name), ephemeral=True) + await _dm_user(bot, kasutaja.id, S.ADMIN["item_dm_removed"].format(item=item_name)) + log.info("ADMINITEM %s %s %s by %s", action_key, ese, kasutaja, interaction.user) diff --git a/economy_prestige_commands.py b/economy_prestige_commands.py new file mode 100644 index 0000000..d9da335 --- /dev/null +++ b/economy_prestige_commands.py @@ -0,0 +1,249 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable + +import discord +from discord import app_commands + +import economy +import strings as S + + +def register_prestige_commands( + tree: app_commands.CommandTree, + check_cmd_rate: Callable[[discord.Interaction], Awaitable[bool]], + ensure_level_role: Callable[[discord.Member, int], Awaitable[None]], +) -> None: + class PrestigeView(discord.ui.View): + def __init__(self, user_id: int, tab: str = "status"): + super().__init__(timeout=60) + self.user_id = user_id + self.tab = tab + + async def _rebuild(self, data: dict): + self.clear_items() + pp = data.get("prestige_points", 0) + upgrades: dict = data.get("prestige_upgrades") or {} + exp = data.get("exp", 0) + level = economy.get_level(exp) + + for tab_id, label in (("status", S.PRESTIGE_UI["btn_tab_status"]), ("shop", S.PRESTIGE_UI["btn_tab_shop"])): + btn = discord.ui.Button( + label=label, + style=discord.ButtonStyle.primary if tab_id == self.tab else discord.ButtonStyle.secondary, + disabled=(tab_id == self.tab), + row=0, + ) + btn.callback = self._switch_tab(tab_id) + self.add_item(btn) + + if self.tab == "status" and level >= economy.PRESTIGE_MIN_LEVEL: + confirm_btn = discord.ui.Button( + label=S.PRESTIGE_UI["btn_confirm"], + style=discord.ButtonStyle.danger, + row=1, + ) + confirm_btn.callback = self._do_prestige() + cancel_btn = discord.ui.Button( + label=S.PRESTIGE_UI["btn_cancel"], + style=discord.ButtonStyle.secondary, + row=1, + ) + cancel_btn.callback = self._do_cancel() + self.add_item(confirm_btn) + self.add_item(cancel_btn) + elif self.tab == "shop": + for uid, item in economy.PRESTIGE_SHOP.items(): + cur_level = upgrades.get(uid, 0) + if cur_level >= item["max_level"]: + continue + cost = item["pp_cost"] + btn = discord.ui.Button( + label=S.PRESTIGE_UI["btn_buy_upgrade"].format(emoji=item["emoji"], name=S.PRESTIGE_SHOP_NAMES[uid], cost=cost), + style=discord.ButtonStyle.success if pp >= cost else discord.ButtonStyle.secondary, + disabled=(pp < cost), + row=1, + ) + btn.callback = self._buy_upgrade(uid) + self.add_item(btn) + + def _build_status_embed(self, data: dict) -> discord.Embed: + exp = data.get("exp", 0) + level = economy.get_level(exp) + pp = data.get("prestige_points", 0) + p_level = data.get("prestige_level", 0) + if level >= economy.PRESTIGE_MIN_LEVEL: + pp_preview = max(1, exp // 1000) + embed = discord.Embed( + title=S.TITLE["prestige_confirm"], + description=S.PRESTIGE_UI["confirm_desc"].format(level=level, exp=exp, pp=pp_preview), + color=0xF4C430, + ) + else: + embed = discord.Embed( + title=S.TITLE["prestige_too_low"], + description=S.PRESTIGE_UI["too_low_desc"].format(level=level, required=economy.PRESTIGE_MIN_LEVEL), + color=0xED4245, + ) + if p_level > 0: + embed.set_footer(text=S.PRESTIGE_UI["status_footer"].format(level=p_level, pp=pp)) + return embed + + def _build_shop_embed(self, data: dict) -> discord.Embed: + pp = data.get("prestige_points", 0) + upgrades: dict = data.get("prestige_upgrades") or {} + embed = discord.Embed( + title=S.TITLE["prestige_shop"], + description=S.PRESTIGE_UI["shop_desc"].format(pp=pp), + color=0xF4C430, + ) + for uid, item in economy.PRESTIGE_SHOP.items(): + cur_level = upgrades.get(uid, 0) + name = f"{item['emoji']} {S.PRESTIGE_SHOP_NAMES[uid]}" + cost_str = S.PRESTIGE_UI["shop_maxed"] if cur_level >= item["max_level"] else S.PRESTIGE_UI["shop_cost_fmt"].format(cost=item["pp_cost"]) + level_str = S.PRESTIGE_UI["shop_level_fmt"].format(cur=cur_level, max=item["max_level"]) + embed.add_field(name=f"{name} · {level_str} · {cost_str}", value=S.PRESTIGE_SHOP_DESCRIPTIONS[uid], inline=False) + return embed + + def _switch_tab(self, tab_id: str): + async def _cb(interaction: discord.Interaction): + if interaction.user.id != self.user_id: + await interaction.response.send_message(S.ERR["not_your_menu"], ephemeral=True) + return + self.tab = tab_id + await interaction.response.defer() + data = await economy.get_user(self.user_id) + await self._rebuild(data) + embed = self._build_status_embed(data) if tab_id == "status" else self._build_shop_embed(data) + await interaction.edit_original_response(embed=embed, view=self) + + return _cb + + def _do_prestige(self): + async def _cb(interaction: discord.Interaction): + if interaction.user.id != self.user_id: + await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) + return + await interaction.response.defer() + res = await economy.do_prestige(self.user_id) + self.clear_items() + if not res["ok"]: + embed = discord.Embed( + title=S.TITLE["prestige_too_low"], + description=S.PRESTIGE_UI["too_low_desc"].format(level=res.get("level", 0), required=res.get("required", 30)), + color=0xED4245, + ) + else: + embed = discord.Embed( + title=S.TITLE["prestige_success"].format(level=res["prestige_level"]), + description=S.PRESTIGE_UI["success_desc"].format(pp=res["pp_earned"], level=res["prestige_level"], total_pp=res["prestige_points"]), + color=0xF4C430, + ) + await interaction.edit_original_response(embed=embed, view=self) + if res.get("ok") and interaction.guild: + member = interaction.guild.get_member(self.user_id) + if member: + asyncio.create_task(ensure_level_role(member, 1)) + + return _cb + + def _do_cancel(self): + async def _cb(interaction: discord.Interaction): + if interaction.user.id != self.user_id: + await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) + return + self.clear_items() + await interaction.response.edit_message(view=self) + + return _cb + + def _buy_upgrade(self, upgrade_id: str): + async def _cb(interaction: discord.Interaction): + if interaction.user.id != self.user_id: + await interaction.response.send_message(S.ERR["not_your_menu"], ephemeral=True) + return + await interaction.response.defer() + res = await economy.do_prestige_buy(self.user_id, upgrade_id) + if not res["ok"]: + if res["reason"] == "insufficient_pp": + err = S.PRESTIGE_UI["buy_no_pp"].format(have=res["have"], need=res["need"]) + elif res["reason"] == "maxed": + err = S.PRESTIGE_UI["buy_maxed"] + else: + err = S.ERR["generic_error"].format(error=res["reason"]) + await interaction.followup.send(err, ephemeral=True) + return + data = await economy.get_user(self.user_id) + await self._rebuild(data) + embed = self._build_shop_embed(data) + item = economy.PRESTIGE_SHOP[res["upgrade_id"]] + name = f"{item['emoji']} {S.PRESTIGE_SHOP_NAMES[res['upgrade_id']]}" + embed.description = S.PRESTIGE_UI["buy_success_desc"].format( + name=name, + new_level=res["new_level"], + max_level=item["max_level"], + pp=res["pp_remaining"], + ) + "\n\n" + (embed.description or "") + await interaction.edit_original_response(embed=embed, view=self) + + return _cb + + async def on_timeout(self): + self.clear_items() + + @tree.command(name="prestige", description=S.CMD["prestige"]) + @app_commands.guild_only() + async def cmd_prestige(interaction: discord.Interaction): + if await check_cmd_rate(interaction): + return + data = await economy.get_user(interaction.user.id) + if data.get("eco_banned"): + await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) + return + view = PrestigeView(interaction.user.id) + await view._rebuild(data) + embed = view._build_status_embed(data) + await interaction.response.send_message(embed=embed, view=view, ephemeral=True) + + @tree.command(name="prestigeshop", description=S.CMD["prestigeshop"]) + async def cmd_prestigeshop(interaction: discord.Interaction): + data = await economy.get_user(interaction.user.id) + view = PrestigeView(interaction.user.id, tab="shop") + await view._rebuild(data) + embed = view._build_shop_embed(data) + await interaction.response.send_message(embed=embed, view=view, ephemeral=True) + + @tree.command(name="prestigebuy", description=S.CMD["prestigebuy"]) + @app_commands.describe(upgrade=S.OPT["prestigebuy_upgrade"]) + async def cmd_prestigebuy(interaction: discord.Interaction, upgrade: str): + if await check_cmd_rate(interaction): + return + res = await economy.do_prestige_buy(interaction.user.id, upgrade.strip().lower()) + if not res["ok"]: + if res["reason"] == "banned": + await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) + elif res["reason"] == "not_found": + await interaction.response.send_message(S.PRESTIGE_UI["buy_not_found"], ephemeral=True) + elif res["reason"] == "maxed": + await interaction.response.send_message(S.PRESTIGE_UI["buy_maxed"], ephemeral=True) + elif res["reason"] == "insufficient_pp": + await interaction.response.send_message( + S.PRESTIGE_UI["buy_no_pp"].format(have=res["have"], need=res["need"]), + ephemeral=True, + ) + return + + item = economy.PRESTIGE_SHOP[res["upgrade_id"]] + name = f"{item['emoji']} {S.PRESTIGE_SHOP_NAMES[res['upgrade_id']]}" + embed = discord.Embed( + title=S.TITLE["prestige_buy_ok"], + description=S.PRESTIGE_UI["buy_success_desc"].format( + name=name, + new_level=res["new_level"], + max_level=res["max_level"], + pp=res["pp_remaining"], + ), + color=0x57F287, + ) + await interaction.response.send_message(embed=embed, ephemeral=True) diff --git a/ops_admin_commands.py b/ops_admin_commands.py new file mode 100644 index 0000000..41d8ec7 --- /dev/null +++ b/ops_admin_commands.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import asyncio +import datetime +import json +import logging +import os +import subprocess +import sys +from collections.abc import Awaitable, Callable +from pathlib import Path + +import discord +from discord import app_commands + +import strings as S + + +def register_ops_admin_commands( + tree: app_commands.CommandTree, + bot: discord.Client, + log: logging.Logger, + process, + start_time: datetime.datetime, + log_dir: Path, + guild_obj: discord.Object, + restart_file: Path, + get_member_cache_size: Callable[[], int], + get_paused: Callable[[], bool], + set_paused: Callable[[bool], None], + count_economy_users: Callable[[], Awaitable[int]], +) -> None: + @tree.command(name="status", description=S.CMD["status"]) + @app_commands.guild_only() + @app_commands.default_permissions(manage_guild=True) + async def cmd_status(interaction: discord.Interaction): + mem = process.memory_info() + cpu = process.cpu_percent(interval=0.1) + uptime = datetime.datetime.now() - start_time + hours, rem = divmod(int(uptime.total_seconds()), 3600) + minutes, seconds = divmod(rem, 60) + tasks_count = len(asyncio.all_tasks()) + latency_ms = round(bot.latency * 1000, 1) + cache_size = get_member_cache_size() + user_count = await count_economy_users() + + embed = discord.Embed(title=S.STATUS_UI["title"], color=0x57F287) + embed.add_field( + name=S.STATUS_UI["uptime_field"], + value=S.STATUS_UI["uptime_val"].format(hours=hours, minutes=minutes, seconds=seconds), + inline=True, + ) + embed.add_field( + name=S.STATUS_UI["latency_field"], + value=S.STATUS_UI["latency_val"].format(ms=latency_ms), + inline=True, + ) + embed.add_field( + name=S.STATUS_UI["ram_field"], + value=S.STATUS_UI["ram_val"].format(mb=f"{mem.rss / 1024**2:.1f}"), + inline=True, + ) + embed.add_field( + name=S.STATUS_UI["cpu_field"], + value=S.STATUS_UI["cpu_val"].format(percent=f"{cpu:.1f}"), + inline=True, + ) + embed.add_field( + name=S.STATUS_UI["tasks_field"], + value=str(tasks_count), + inline=True, + ) + embed.add_field( + name=S.STATUS_UI["eco_players_field"], + value=str(user_count), + inline=True, + ) + embed.add_field( + name=S.STATUS_UI["members_cache_field"], + value=str(cache_size), + inline=True, + ) + + log_lines = [ + S.STATUS_UI["log_line"].format(name=p.name, size_kb=f"{p.stat().st_size / 1024:.1f}") + for p in sorted(log_dir.glob("*.log*")) + if p.is_file() + ] + embed.add_field( + name=S.STATUS_UI["log_files_field"], + value="\n".join(log_lines) or S.STATUS_UI["none"], + inline=False, + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + + @tree.command(name="sync", description=S.CMD["sync"]) + @app_commands.guild_only() + @app_commands.default_permissions(manage_guild=True) + async def cmd_sync(interaction: discord.Interaction): + await interaction.response.defer(ephemeral=True) + tree.copy_global_to(guild=guild_obj) + await tree.sync(guild=guild_obj) + tree.clear_commands(guild=None) + await tree.sync() + await interaction.followup.send(S.MSG_SYNC_DONE, ephemeral=True) + log.info("/sync triggered by %s", interaction.user) + + @tree.command(name="restart", description=S.CMD["restart"]) + @app_commands.guild_only() + @app_commands.default_permissions(manage_guild=True) + async def cmd_restart(interaction: discord.Interaction): + restart_file.write_text(json.dumps({"channel_id": interaction.channel_id}), encoding="utf-8") + await interaction.response.send_message(S.MSG_RESTARTING, ephemeral=True) + log.info("/restart triggered by %s", interaction.user) + subprocess.Popen([sys.executable] + sys.argv, cwd=os.getcwd()) + await bot.close() + + @tree.command(name="shutdown", description=S.CMD["shutdown"]) + @app_commands.guild_only() + @app_commands.default_permissions(manage_guild=True) + async def cmd_shutdown(interaction: discord.Interaction): + await interaction.response.send_message(S.MSG_SHUTTING_DOWN, ephemeral=True) + log.info("/shutdown triggered by %s", interaction.user) + await bot.close() + + @tree.command(name="pause", description=S.CMD["pause"]) + @app_commands.guild_only() + @app_commands.default_permissions(manage_guild=True) + async def cmd_pause(interaction: discord.Interaction): + paused = not get_paused() + set_paused(paused) + msg = S.MSG_PAUSED if paused else S.MSG_UNPAUSED + log.info("/pause toggled → %s by %s", "PAUSED" if paused else "UNPAUSED", interaction.user) + await interaction.response.send_message(msg, ephemeral=True) diff --git a/ops_channel_commands.py b/ops_channel_commands.py new file mode 100644 index 0000000..0e30f4d --- /dev/null +++ b/ops_channel_commands.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import logging +from collections.abc import Callable + +import discord +from discord import app_commands + +import economy +import strings as S + + +def register_ops_channel_commands( + tree: app_commands.CommandTree, + bot: discord.Client, + log: logging.Logger, + get_allowed_channels: Callable[[], list[int]], + set_allowed_channels: Callable[[list[int]], None], +) -> None: + @tree.command(name="send", description=S.CMD["send"]) + @app_commands.guild_only() + @app_commands.describe( + kanal=S.OPT["send_kanal"], + sõnum=S.OPT["send_sõnum"], + ) + @app_commands.default_permissions(manage_guild=True) + async def cmd_send(interaction: discord.Interaction, kanal: discord.TextChannel, sõnum: str): + try: + await kanal.send(sõnum) + await interaction.response.send_message( + S.SEND_UI["sent"].format(channel=kanal.mention), ephemeral=True + ) + except discord.Forbidden: + await interaction.response.send_message( + S.SEND_UI["forbidden"].format(channel=kanal.mention), ephemeral=True + ) + except Exception as e: + await interaction.response.send_message( + S.ERR["send_failed"].format(error=e), ephemeral=True + ) + + @tree.command(name="economysetup", description=S.CMD["economysetup"]) + @app_commands.guild_only() + @app_commands.default_permissions(manage_guild=True) + async def cmd_economysetup(interaction: discord.Interaction): + await interaction.response.defer(ephemeral=True) + guild = interaction.guild + bot_member = guild.get_member(bot.user.id) + bot_top_pos = max((r.position for r in bot_member.roles if r.managed), default=1) + + all_role_names = [economy.ECONOMY_ROLE] + [name for _, name in economy.LEVEL_ROLES] + + created, existing = [], [] + for name in all_role_names: + role = discord.utils.find(lambda r, n=name: r.name == n, guild.roles) + if role is None: + await guild.create_role(name=name, reason="/economysetup") + created.append(name) + else: + existing.append(name) + + positions: dict[discord.Role, int] = {} + base = max(bot_top_pos - 1, 1) + for i, name in enumerate(all_role_names): + role = discord.utils.find(lambda r, n=name: r.name == n, guild.roles) + if role: + positions[role] = max(base - i, 1) + if positions: + try: + await guild.edit_role_positions(positions=positions) + except discord.Forbidden: + pass + + embed = discord.Embed(title=S.TITLE["economysetup"], color=0x57F287) + if created: + embed.add_field(name=S.ECONOMYSETUP_UI["created_field"], value="\n".join(created), inline=True) + if existing: + embed.add_field(name=S.ECONOMYSETUP_UI["existing_field"], value="\n".join(existing), inline=True) + embed.set_footer(text=S.ECONOMYSETUP_UI["footer"]) + await interaction.followup.send(embed=embed, ephemeral=True) + log.info("/economysetup triggered by %s", interaction.user) + + @tree.command(name="allowchannel", description=S.CMD["allowchannel"]) + @app_commands.guild_only() + @app_commands.describe(kanal=S.OPT["allowchannel_kanal"]) + @app_commands.default_permissions(manage_guild=True) + async def cmd_allowchannel(interaction: discord.Interaction, kanal: discord.TextChannel): + allowed = get_allowed_channels() + if kanal.id in allowed: + await interaction.response.send_message( + S.CHANNEL_UI["already_allowed"].format(channel=kanal.mention), ephemeral=True + ) + return + allowed.append(kanal.id) + set_allowed_channels(allowed) + log.info("ALLOWCHANNEL +%s by %s", kanal, interaction.user) + await interaction.response.send_message( + S.CHANNEL_UI["added"].format(channel=kanal.mention), ephemeral=True + ) + + @tree.command(name="denychannel", description=S.CMD["denychannel"]) + @app_commands.guild_only() + @app_commands.describe(kanal=S.OPT["denychannel_kanal"]) + @app_commands.default_permissions(manage_guild=True) + async def cmd_denychannel(interaction: discord.Interaction, kanal: discord.TextChannel): + allowed = get_allowed_channels() + if kanal.id not in allowed: + await interaction.response.send_message( + S.CHANNEL_UI["not_in_list"].format(channel=kanal.mention), ephemeral=True + ) + return + allowed.remove(kanal.id) + set_allowed_channels(allowed) + log.info("DENYCHANNEL -%s by %s", kanal, interaction.user) + if allowed: + await interaction.response.send_message( + S.CHANNEL_UI["removed"].format(channel=kanal.mention), ephemeral=True + ) + else: + await interaction.response.send_message( + S.CHANNEL_UI["removed_last"].format(channel=kanal.mention), ephemeral=True + ) + + @tree.command(name="channels", description=S.CMD["channels"]) + @app_commands.guild_only() + @app_commands.default_permissions(manage_guild=True) + async def cmd_channels(interaction: discord.Interaction): + allowed = get_allowed_channels() + if not allowed: + desc = S.CHANNEL_UI["list_empty"] + else: + lines = "\n".join(f"• <#{cid}>" for cid in allowed) + desc = S.CHANNEL_UI["list_filled"].format(lines=lines) + embed = discord.Embed(title=S.CHANNEL_UI["list_title"], description=desc, color=0x5865F2) + await interaction.response.send_message(embed=embed, ephemeral=True) diff --git a/pb_client.py b/pb_client.py index c217c64..abce617 100644 --- a/pb_client.py +++ b/pb_client.py @@ -7,24 +7,26 @@ 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 """ from __future__ import annotations import asyncio import logging -import os import time from typing import Any import aiohttp +import config + _log = logging.getLogger("tipiCOIN.pb") -PB_URL = os.getenv("PB_URL", "http://127.0.0.1:8090") -PB_ADMIN_EMAIL = os.getenv("PB_ADMIN_EMAIL", "") -PB_ADMIN_PASSWORD = os.getenv("PB_ADMIN_PASSWORD", "") -ECONOMY_COLLECTION = "economy_users" +PB_URL = config.PB_URL +PB_ADMIN_EMAIL = config.PB_ADMIN_EMAIL +PB_ADMIN_PASSWORD = config.PB_ADMIN_PASSWORD +ECONOMY_COLLECTION = config.PB_ECONOMY_COLLECTION _TIMEOUT = aiohttp.ClientTimeout(total=10) diff --git a/scripts/add_stats_fields.py b/scripts/add_stats_fields.py index 0dca7fa..001769f 100644 --- a/scripts/add_stats_fields.py +++ b/scripts/add_stats_fields.py @@ -11,7 +11,6 @@ Requirements: from __future__ import annotations import asyncio -import os import sys from pathlib import Path @@ -21,10 +20,12 @@ from dotenv import load_dotenv sys.path.insert(0, str(Path(__file__).parent.parent)) load_dotenv() -PB_URL = os.getenv("PB_URL", "http://127.0.0.1:8090") -PB_ADMIN_EMAIL = os.getenv("PB_ADMIN_EMAIL", "") -PB_ADMIN_PASSWORD = os.getenv("PB_ADMIN_PASSWORD", "") -COLLECTION = "economy_users" +import config # noqa: E402 + +PB_URL = config.PB_URL +PB_ADMIN_EMAIL = config.PB_ADMIN_EMAIL +PB_ADMIN_PASSWORD = config.PB_ADMIN_PASSWORD +COLLECTION = config.PB_ECONOMY_COLLECTION # --------------------------------------------------------------------------- # New fields to add diff --git a/scripts/reset_pb_collections.py b/scripts/reset_pb_collections.py new file mode 100644 index 0000000..c4bda08 --- /dev/null +++ b/scripts/reset_pb_collections.py @@ -0,0 +1,180 @@ +"""Destructively recreate economy PocketBase collections for dev + economy profiles. + +Usage: + python scripts/reset_pb_collections.py --confirm + +This will DELETE and recreate the collections configured by: +- PB_ECONOMY_COLLECTION_DEV +- PB_ECONOMY_COLLECTION_ECONOMY +""" + +from __future__ import annotations + +import argparse +import asyncio +import sys +from pathlib import Path + +import aiohttp +from dotenv import load_dotenv + +sys.path.insert(0, str(Path(__file__).parent.parent)) +load_dotenv() + +import config # noqa: E402 + +PB_URL = config.PB_URL +PB_ADMIN_EMAIL = config.PB_ADMIN_EMAIL +PB_ADMIN_PASSWORD = config.PB_ADMIN_PASSWORD + + +def _text_field(name: str, required: bool = False) -> dict: + return { + "name": name, + "type": "text", + "required": required, + "options": {"min": None, "max": None, "pattern": ""}, + } + + +def _number_field(name: str) -> dict: + return { + "name": name, + "type": "number", + "required": False, + "options": {"min": None, "max": None, "noDecimal": False}, + } + + +def _bool_field(name: str) -> dict: + return {"name": name, "type": "bool", "required": False} + + +def _json_field(name: str) -> dict: + return {"name": name, "type": "json", "required": False} + + +def _collection_payload(name: str) -> dict: + fields = [ + _text_field("user_id", required=True), + _number_field("balance"), + _number_field("exp"), + _number_field("daily_streak"), + _text_field("last_daily"), + _text_field("last_work"), + _text_field("last_beg"), + _text_field("last_crime"), + _text_field("last_rob"), + _text_field("last_heist"), + _text_field("last_streak_date"), + _text_field("jailed_until"), + _text_field("last_fish"), + _json_field("items"), + _json_field("item_uses"), + _json_field("reminders"), + _json_field("prestige_upgrades"), + _json_field("fish_book"), + _json_field("fish_inventory"), + _bool_field("eco_banned"), + _bool_field("jailbreak_used"), + _number_field("heist_global_cd_until"), + _number_field("peak_balance"), + _number_field("lifetime_earned"), + _number_field("lifetime_lost"), + _number_field("work_count"), + _number_field("beg_count"), + _number_field("total_wagered"), + _number_field("biggest_win"), + _number_field("biggest_loss"), + _number_field("slots_jackpots"), + _number_field("crimes_attempted"), + _number_field("crimes_succeeded"), + _number_field("times_jailed"), + _number_field("total_bail_paid"), + _number_field("heists_joined"), + _number_field("heists_won"), + _number_field("total_given"), + _number_field("total_received"), + _number_field("best_daily_streak"), + _number_field("prestige_level"), + _number_field("prestige_points"), + _number_field("season_total_exp"), + _number_field("total_fish_caught"), + ] + + 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", + json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD}, + ) as resp: + if resp.status != 200: + raise RuntimeError(f"Auth failed ({resp.status}): {await resp.text()}") + return (await resp.json())["token"] + + +async def _delete_if_exists(session: aiohttp.ClientSession, headers: dict[str, str], name: str) -> None: + async with session.get(f"{PB_URL}/api/collections/{name}", headers=headers) as resp: + if resp.status == 404: + print(f"[SKIP] {name} does not exist") + return + if resp.status != 200: + raise RuntimeError(f"Could not fetch {name} ({resp.status}): {await resp.text()}") + + async with session.delete(f"{PB_URL}/api/collections/{name}", headers=headers) as resp: + if resp.status not in (200, 204): + raise RuntimeError(f"Delete failed for {name} ({resp.status}): {await resp.text()}") + print(f"[DELETE] {name}") + + +async def _create_collection(session: aiohttp.ClientSession, headers: dict[str, str], name: str) -> None: + payload = _collection_payload(name) + 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()}") + print(f"[CREATE] {name}") + + +async def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--confirm", action="store_true", help="Required flag to run destructive reset") + args = parser.parse_args() + + 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) + + if not targets: + raise SystemExit("No target collections configured.") + + timeout = aiohttp.ClientTimeout(total=20) + async with aiohttp.ClientSession(timeout=timeout) as session: + token = await _auth_token(session) + headers = {"Authorization": token} + + for name in targets: + await _delete_if_exists(session, headers, name) + await _create_collection(session, headers, name) + + print("\nDone. Collections recreated:") + for name in targets: + print(f" - {name}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/strings.py b/strings.py index db47946..18cfd63 100644 --- a/strings.py +++ b/strings.py @@ -858,6 +858,11 @@ BIRTHDAY_UI: dict[str, str] = { "footer": "Leht {month}/12 · {month_name}", } +BIRTHDAY_MONTHS: list[str] = [ + "Jaanuar", "Veebruar", "Märts", "Aprill", "Mai", "Juuni", + "Juuli", "August", "September", "Oktoober", "November", "Detsember", +] + # --------------------------------------------------------------------------- # /check summary strings # --------------------------------------------------------------------------- @@ -870,6 +875,7 @@ CHECK_UI: dict[str, str] = { "stat_uid": "Kasutaja ID", "stat_discord": "Discordi kasutajanimi", "stat_bday": "Sünnipäev", + "no_name": "(no name)", "done": "**Kontroll lõpetatud!**", "already_ok": "✅ Juba korras: {count}", "fixed": "🔧 Parandatud: {count}", @@ -878,9 +884,35 @@ CHECK_UI: dict[str, str] = { "errors": "⚠️ Vead: {count}", "details_header": "**Üksikasjad:**", "details_more": "... ja {count} rohkem", + "detail_error": "⚠️ {error}", + "detail_nickname": "hüüdnimi", + "detail_roles_added": "+rollid: {roles}", + "detail_changed": "🔧 **{name}**: {parts}", "ids_filled": "\n🔑 Täideti **{count}** puuduvat kasutaja ID-d.", } +# --------------------------------------------------------------------------- +# /status UI +# --------------------------------------------------------------------------- + +STATUS_UI: dict[str, str] = { + "title": "🖥️ Boti olek", + "uptime_field": "🕐 Uptime", + "uptime_val": "{hours}t {minutes}m {seconds}s", + "latency_field": "📡 Latency", + "latency_val": "{ms} ms", + "ram_field": "🧠 RAM (RSS)", + "ram_val": "{mb} MB", + "cpu_field": "⚙️ CPU", + "cpu_val": "{percent}%", + "tasks_field": "🔄 Async tasks", + "eco_players_field": "👤 Eco players", + "members_cache_field": "📋 Liikmed (cache)", + "log_files_field": "📂 Log files", + "log_line": "`{name}` - {size_kb} KB", + "none": "-", +} + # --------------------------------------------------------------------------- # Admin command responses and DMs # ---------------------------------------------------------------------------