"""TipiLAN Bot - Discord member management powered by Google Sheets.""" import asyncio import collections import datetime 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 import discord from discord import app_commands from discord.ext import tasks import colorlog import psutil 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 # --------------------------------------------------------------------------- # Logging # --------------------------------------------------------------------------- _LOG_DIR = Path("logs") _LOG_DIR.mkdir(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") # Console handler - coloured output _color_fmt = colorlog.ColoredFormatter( "%(log_color)s%(asctime)s [%(levelname)-8s]%(reset)s %(cyan)s%(name)s%(reset)s: %(message)s", log_colors={ "DEBUG": "white", "INFO": "green", "WARNING": "yellow,bold", "ERROR": "red,bold", "CRITICAL": "red,bg_white,bold", }, ) _console = logging.StreamHandler() _console.setFormatter(_color_fmt) _console.setLevel(logging.INFO) # General rotating file: logs/bot.log (5 MB x 5 backups) _file_h = logging.handlers.RotatingFileHandler( _LOG_DIR / "bot.log", maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8" ) _file_h.setFormatter(_fmt) _file_h.setLevel(logging.INFO) logging.getLogger().setLevel(logging.INFO) logging.getLogger().addHandler(_console) logging.getLogger().addHandler(_file_h) # Transaction log: logs/transactions.log (daily rotation, 30 days) _txn_h = logging.handlers.TimedRotatingFileHandler( _LOG_DIR / "transactions.log", when="midnight", backupCount=30, encoding="utf-8" ) _txn_h.setFormatter(_txn_fmt) _txn_h.setLevel(logging.INFO) _txn_logger = logging.getLogger("tipiCOIN.txn") _txn_logger.addHandler(_txn_h) _txn_logger.propagate = False # don't double-log to console/bot.log log = logging.getLogger("tipilan") # --------------------------------------------------------------------------- # Bot setup # --------------------------------------------------------------------------- intents = discord.Intents.default() intents.members = True # Required: Server Members Intent must be ON in dev portal bot = discord.Client(intents=intents) tree = app_commands.CommandTree(bot) GUILD_OBJ = discord.Object(id=config.GUILD_ID) TALLINN_TZ = ZoneInfo("Europe/Tallinn") _start_time = datetime.datetime.now() _process = psutil.Process() _DATA_DIR = Path("data") _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) _spam_tracker: dict[int, collections.deque] = {} # user_id -> deque of recent income-cmd timestamps _SPAM_WINDOW = 5.0 # seconds _SPAM_THRESHOLD = 5 # income commands within window triggers jail _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 def _load_bot_config() -> dict: if _BOT_CONFIG.exists(): try: return json.loads(_BOT_CONFIG.read_text(encoding="utf-8")) except Exception: pass return {"allowed_channels": []} def _save_bot_config(cfg: dict) -> None: _DATA_DIR.mkdir(exist_ok=True) _BOT_CONFIG.write_text(json.dumps(cfg, indent=2), encoding="utf-8") def _get_allowed_channels() -> list[int]: return [int(c) for c in _load_bot_config().get("allowed_channels", [])] def _set_allowed_channels(channel_ids: list[int]) -> None: cfg = _load_bot_config() cfg["allowed_channels"] = [str(c) for c in channel_ids] _save_bot_config(cfg) # --------------------------------------------------------------------------- # EXP / Level role helpers # --------------------------------------------------------------------------- def _level_role_name(level: int) -> str: return economy.level_role_name(level) async def _apply_level_role(member: discord.Member, new_level: int, old_level: int) -> None: """Swap vanity role when the user crosses a tier boundary.""" new_role_name = _level_role_name(new_level) old_role_name = _level_role_name(old_level) if new_role_name == old_role_name: return guild = member.guild old_role = discord.utils.find(lambda r: r.name == old_role_name, guild.roles) if old_role and old_role in member.roles: try: await member.remove_roles(old_role, reason="Level up") except discord.Forbidden: pass new_role = discord.utils.find(lambda r: r.name == new_role_name, guild.roles) if new_role: try: await member.add_roles(new_role, reason="Level up") except discord.Forbidden: pass async def _ensure_level_role(member: discord.Member, level: int) -> None: """Make sure the user has exactly the right vanity role + ECONOMY base role (idempotent).""" correct_name = _level_role_name(level) all_role_names = {name for _, name in economy.LEVEL_ROLES} guild = member.guild for role in member.roles: if role.name in all_role_names and role.name != correct_name: try: await member.remove_roles(role, reason="Role sync") except discord.Forbidden: pass correct_role = discord.utils.find(lambda r: r.name == correct_name, guild.roles) if correct_role and correct_role not in member.roles: try: await member.add_roles(correct_role, reason="Role sync") except discord.Forbidden: pass economy_role = discord.utils.find(lambda r: r.name == economy.ECONOMY_ROLE, guild.roles) if economy_role and economy_role not in member.roles: try: await member.add_roles(economy_role, reason="Economy member") except discord.Forbidden: pass async def _award_exp(interaction: discord.Interaction, amount: int) -> None: """Award EXP and post a public level-up notice if the user reaches a new tier.""" result = await economy.award_exp(interaction.user.id, amount) member = interaction.guild.get_member(interaction.user.id) if interaction.guild else None if member: economy_role = discord.utils.find(lambda r: r.name == economy.ECONOMY_ROLE, interaction.guild.roles) if economy_role and economy_role not in member.roles: try: await member.add_roles(economy_role, reason="Economy member") except discord.Forbidden: pass if result["new_level"] <= result["old_level"]: return if member: await _apply_level_role(member, result["new_level"], result["old_level"]) new_role = _level_role_name(result["new_level"]) old_role = _level_role_name(result["old_level"]) extra = S.MSG_LEVELUP_ROLE.format(role=new_role) if new_role != old_role else "" try: await interaction.followup.send( S.MSG_LEVELUP.format(name=interaction.user.display_name, level=result["new_level"], extra=extra), ephemeral=False, ) except Exception: pass @tree.interaction_check async def _log_command(interaction: discord.Interaction) -> bool: """Log every slash command invocation and enforce allowed-channel restriction.""" if interaction.command: opts = interaction.data.get("options", []) opts_str = " ".join(f"{o['name']}={o.get('value', '?')}" for o in opts) if opts else "" log.info( "CMD /%s user=%s (%s)%s", interaction.command.name, interaction.user.id, interaction.user.display_name, f" [{opts_str}]" if opts_str else "", ) # DMs always pass if interaction.guild is None: return True # Admins (manage_guild) can use commands in any channel (and bypass pause) member = interaction.user if hasattr(member, "guild_permissions") and member.guild_permissions.manage_guild: return True # Maintenance mode: block all non-admin commands if _PAUSED: await interaction.response.send_message(S.MSG_MAINTENANCE, ephemeral=True) return False allowed = _get_allowed_channels() if not allowed: return True # no restriction configured if interaction.channel_id in allowed: return True mentions = " ".join(f"<#{cid}>" for cid in allowed) await interaction.response.send_message( S.ERR["channel_only"].format(channels=mentions), ephemeral=True, ) return False def _load_bday_log() -> dict: try: return json.loads(_BDAY_LOG.read_text(encoding="utf-8")) except (FileNotFoundError, json.JSONDecodeError): return {} def _has_announced_today(discord_id: int) -> bool: today = str(datetime.date.today()) return str(discord_id) in _load_bday_log().get(today, []) def _mark_announced_today(discord_id: int) -> None: log_data = _load_bday_log() today = str(datetime.date.today()) today_list = log_data.setdefault(today, []) uid = str(discord_id) if uid not in today_list: today_list.append(uid) cutoff = str(datetime.date.today() - datetime.timedelta(days=2)) log_data = {k: v for k, v in log_data.items() if k >= cutoff} _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) 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) @birthday_daily.before_loop async def before_birthday_daily(): await bot.wait_until_ready() # --------------------------------------------------------------------------- # Rotating rich presence # --------------------------------------------------------------------------- _presence_index = 0 _economy_count: int = 0 _PRESENCES: list = [ lambda g: discord.Activity( type=discord.ActivityType.watching, name="/help - kõik käsklused", state="Vaata, mida TipiBOTil pakkuda on", ), lambda g: discord.Activity( type=discord.ActivityType.watching, name="/daily - päevane boonus TipiCOINe", state="Streak boonused kuni x3.0 rohkem 🔥", ), lambda g: discord.Activity( type=discord.ActivityType.watching, name=f"{_economy_count - 1 or '?'} mängijat võistlevad", state="/leaderboard - kes on tipus?", ), ] @tasks.loop(seconds=20) async def _rotate_presence() -> None: global _presence_index, _economy_count guild = bot.get_guild(config.GUILD_ID) try: _economy_count = await pb_client.count_records() except Exception as e: log.warning("Presence: failed to fetch economy count: %s", e) activity = _PRESENCES[_presence_index % len(_PRESENCES)](guild) await bot.change_presence(status=discord.Status.online, activity=activity) _presence_index += 1 @_rotate_presence.before_loop async def _before_rotate_presence(): await bot.wait_until_ready() # --------------------------------------------------------------------------- # Events # --------------------------------------------------------------------------- @bot.event async def on_ready(): """Load sheet data and sync slash commands on startup.""" log.info("Logged in as %s (ID: %s)", bot.user, bot.user.id) economy.set_house(bot.user.id) # 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) # Sync slash commands to the guild only; wipe any leftover global registrations tree.copy_global_to(guild=GUILD_OBJ) await tree.sync(guild=GUILD_OBJ) tree.clear_commands(guild=None) await tree.sync() log.info("Slash commands synced to guild %s (global commands cleared)", config.GUILD_ID) # Start daily birthday task if not birthday_daily.is_running(): birthday_daily.start() log.info("Birthday daily task started (fires 09:00 Tallinn time)") # Start rotating rich presence if not _rotate_presence.is_running(): _rotate_presence.start() log.info("Rich presence rotation started") # Re-schedule any reminder tasks lost on restart await _restore_reminders() # Notify the channel where /restart was triggered if _RESTART_FILE.exists(): try: data = json.loads(_RESTART_FILE.read_text(encoding="utf-8")) ch = await bot.fetch_channel(int(data["channel_id"])) if ch: await ch.send(S.MSG_RESTART_DONE) except Exception as e: log.warning("Could not send restart notification: %s", e) finally: _RESTART_FILE.unlink(missing_ok=True) @bot.event async def on_disconnect(): log.warning("Bot disconnected from Discord gateway") @bot.event async def on_resumed(): log.info("Bot reconnected to Discord (session 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) 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) # --------------------------------------------------------------------------- # 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 = [] 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() 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) @tree.command(name="ping", description=S.CMD["ping"]) async def cmd_ping(interaction: discord.Interaction): await interaction.response.send_message(S.MSG_PONG) # --------------------------------------------------------------------------- # /help # --------------------------------------------------------------------------- _HELP_PAGE_SIZE = 10 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) page_fields = fields[page * _HELP_PAGE_SIZE : (page + 1) * _HELP_PAGE_SIZE] title = cat["label"] if total_pages > 1: title += f" ({page + 1}/{total_pages})" embed = discord.Embed(title=title, description=cat["description"], color=cat["color"]) for name, value in page_fields: embed.add_field(name=name, value=value, inline=False) embed.set_footer(text=S.HELP_UI["footer"]) return embed class HelpView(discord.ui.View): def __init__(self, is_admin: bool = False, category: str = "üldine", page: int = 0): super().__init__(timeout=120) self.is_admin = is_admin self.category = category self.page = page self._rebuild() def _rebuild(self) -> None: self.clear_items() fields = S.HELP_CATEGORIES[self.category]["fields"] total_pages = math.ceil(len(fields) / _HELP_PAGE_SIZE) select_row = 0 if total_pages > 1: select_row = 1 prev_btn = discord.ui.Button( label="◀", style=discord.ButtonStyle.secondary, disabled=(self.page == 0), row=0, ) prev_btn.callback = self._prev next_btn = discord.ui.Button( label="▶", style=discord.ButtonStyle.secondary, disabled=(self.page >= total_pages - 1), row=0, ) next_btn.callback = self._next self.add_item(prev_btn) self.add_item(next_btn) self.add_item(HelpSelect(self.is_admin, self.category, row=select_row)) async def _prev(self, interaction: discord.Interaction) -> None: self.page = max(0, self.page - 1) self._rebuild() 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) self.page = min(total - 1, self.page + 1) self._rebuild() await interaction.response.edit_message(embed=_help_embed(self.category, self.page), view=self) class HelpSelect(discord.ui.Select): def __init__(self, is_admin: bool = False, current: str = "üldine", row: int = 0): options = [ discord.SelectOption( label=v["label"], value=k, description=v["description"], default=(k == current), ) for k, v in S.HELP_CATEGORIES.items() if k != "admin" or is_admin ] super().__init__(placeholder=S.HELP_UI["select_placeholder"], options=options, row=row) async def callback(self, interaction: discord.Interaction) -> None: view = self.view view.category = self.values[0] view.page = 0 view._rebuild() await interaction.response.edit_message(embed=_help_embed(view.category, 0), view=view) @tree.command(name="help", description=S.CMD["help"]) async def cmd_help(interaction: discord.Interaction): perms = interaction.user.guild_permissions if interaction.guild else None is_admin = bool(perms and (perms.manage_roles or perms.manage_guild)) await interaction.response.send_message( embed=_help_embed("üldine"), view=HelpView(is_admin), ephemeral=True ) @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"] 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_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_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.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) # --------------------------------------------------------------------------- # TipiBOT economy commands # --------------------------------------------------------------------------- def _coin(amount: int) -> str: return f"**{amount:,}** {economy.COIN}" def _cd_ts(remaining: datetime.timedelta) -> str: """Discord relative timestamp string for when a cooldown expires.""" expiry = int((datetime.datetime.now(datetime.timezone.utc) + remaining).timestamp()) return f"" async def _check_cmd_rate(interaction: discord.Interaction) -> bool: """Record an income command use. Jails the user and returns True if spam is detected.""" uid = interaction.user.id now = time.monotonic() q = _spam_tracker.setdefault(uid, collections.deque()) q.append(now) while q and now - q[0] > _SPAM_WINDOW: q.popleft() if len(q) >= _SPAM_THRESHOLD: q.clear() await economy.do_spam_jail(uid) await interaction.response.send_message(S.MSG_SPAM_JAIL, ephemeral=True) return True return False 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). Rejects decimals and ambiguous inputs like 1,1 or 1.5. Returns (amount, None) on success or (None, error_msg) on failure.""" v = value.strip() if v.lower() == "all": return balance, None # Strip valid thousand separators: groups of exactly 3 digits after separator if re.fullmatch(r'\d{1,3}([,. ]\d{3})*', v): v = re.sub(r'[,. ]', '', v) try: return int(v), None except ValueError: return None, S.ERR["invalid_amount"] # --------------------------------------------------------------------------- # Reminder system # --------------------------------------------------------------------------- _reminder_tasks: dict[tuple[int, str], asyncio.Task] = {} def _schedule_reminder(user_id: int, cmd: str, delay: datetime.timedelta) -> None: """DM the user when their cooldown expires. Replaces any existing task.""" async def _remind(): await asyncio.sleep(delay.total_seconds()) user = bot.get_user(user_id) if user is None: try: user = await bot.fetch_user(user_id) except (discord.NotFound, discord.HTTPException): user = None if user: try: await user.send( S.MSG_REMINDER.format(cmd=cmd) ) except (discord.Forbidden, discord.HTTPException): pass _reminder_tasks.pop((user_id, cmd), None) key = (user_id, cmd) existing = _reminder_tasks.get(key) if existing and not existing.done(): existing.cancel() _reminder_tasks[key] = asyncio.create_task(_remind()) _REMINDER_COOLDOWN_KEYS: dict[str, str] = { "daily": "last_daily", "work": "last_work", "beg": "last_beg", "crime": "last_crime", "rob": "last_rob", } async def _restore_reminders() -> None: """Re-schedule reminder tasks lost when the bot restarted.""" now = datetime.datetime.now(datetime.timezone.utc) restored = 0 for uid_str, user in (await economy.get_all_users_raw()).items(): reminders = user.get("reminders", []) if not reminders: continue user_id = int(uid_str) for cmd in reminders: last_key = _REMINDER_COOLDOWN_KEYS.get(cmd) if not last_key: continue last_str = user.get(last_key) if not last_str: continue items = user.get("items", []) if cmd == "work" and "monitor" in items: cooldown = datetime.timedelta(minutes=40) elif cmd == "beg" and "hiirematt" in items: cooldown = datetime.timedelta(minutes=3) elif cmd == "daily" and "korvaklapid" in items: cooldown = datetime.timedelta(hours=18) else: cooldown = economy.COOLDOWNS.get(cmd) if not cooldown: continue last_dt = datetime.datetime.fromisoformat(last_str) if last_dt.tzinfo is None: last_dt = last_dt.replace(tzinfo=datetime.timezone.utc) remaining = (last_dt + cooldown) - now if remaining.total_seconds() > 0: _schedule_reminder(user_id, cmd, remaining) restored += 1 if restored: log.info("Restored %d reminder task(s) after restart", restored) async def _maybe_remind(user_id: int, cmd: str) -> None: """Schedule a DM reminder if the user has opted in for this command.""" user_data = await economy.get_user(user_id) if cmd not in user_data.get("reminders", []): return items = set(user_data.get("items", [])) if cmd == "work" and "monitor" in items: delay = datetime.timedelta(minutes=40) elif cmd == "beg" and "hiirematt" in items: delay = datetime.timedelta(minutes=3) elif cmd == "daily" and "korvaklapid" in items: delay = datetime.timedelta(hours=18) else: delay = economy.COOLDOWNS.get(cmd, datetime.timedelta(hours=1)) _schedule_reminder(user_id, cmd, delay) def _balance_embed(user: discord.User | discord.Member, data: dict) -> discord.Embed: embed = discord.Embed( title=f"{economy.COIN} {user.display_name}", color=0xF4C430, ) embed.add_field(name=S.BALANCE_UI["saldo"], value=_coin(data["balance"]), inline=True) streak = data.get("daily_streak", 0) if streak: embed.add_field(name=S.BALANCE_UI["streak"], value=S.BALANCE_UI["streak_val"].format(streak=streak), inline=True) # Jail status jail_remaining = economy._is_jailed(data) if jail_remaining: embed.add_field(name=S.BALANCE_UI["jailed_until"], value=_cd_ts(jail_remaining), inline=True) # Items with uses info item_lines = [] uses_map = data.get("item_uses", {}) for i in data.get("items", []): if i not in economy.SHOP: continue line = f"{economy.SHOP[i]['emoji']} {economy.SHOP[i]['name']}" if i in uses_map: u = uses_map[i] line += S.BALANCE_UI["uses_one"].format(uses=u) if u == 1 else S.BALANCE_UI["uses_many"].format(uses=u) item_lines.append(line) if item_lines: embed.add_field(name=S.BALANCE_UI["items"], value="\n".join(item_lines), inline=False) return embed @tree.command(name="balance", description=S.CMD["balance"]) async def cmd_balance(interaction: discord.Interaction, kasutaja: discord.Member | None = None): target = kasutaja or interaction.user data = await economy.get_user(target.id) await interaction.response.send_message(embed=_balance_embed(target, data)) @tree.command(name="cooldowns", description=S.CMD["cooldowns"]) async def cmd_cooldowns(interaction: discord.Interaction): data = await economy.get_user(interaction.user.id) now = datetime.datetime.now(datetime.timezone.utc) items = set(data.get("items", [])) def _status(last_key: str, cd: datetime.timedelta) -> str: raw = data.get(last_key) if not raw: return S.COOLDOWNS_UI["ready"] last = economy._parse_dt(raw) if last is None: return S.COOLDOWNS_UI["ready"] expires = last + cd if expires <= now: return S.COOLDOWNS_UI["ready"] ts = int(expires.timestamp()) return f"⏳ " work_cd = datetime.timedelta(minutes=40) if "monitor" in items else economy.COOLDOWNS["work"] beg_cd = datetime.timedelta(minutes=3) if "hiirematt" in items else economy.COOLDOWNS["beg"] daily_cd = datetime.timedelta(hours=18) if "korvaklapid" in items else economy.COOLDOWNS["daily"] lines = [ S.COOLDOWNS_UI["daily_line"].format(status=_status("last_daily", daily_cd), note=S.COOLDOWNS_UI["note_korvak"] if "korvaklapid" in items else ""), S.COOLDOWNS_UI["work_line"].format(status=_status("last_work", work_cd), note=S.COOLDOWNS_UI["note_monitor"] if "monitor" in items else ""), S.COOLDOWNS_UI["beg_line"].format(status=_status("last_beg", beg_cd), note=S.COOLDOWNS_UI["note_hiirematt"] if "hiirematt" in items else ""), S.COOLDOWNS_UI["crime_line"].format(status=_status("last_crime", economy.COOLDOWNS["crime"])), S.COOLDOWNS_UI["rob_line"].format(status=_status("last_rob", economy.COOLDOWNS["rob"])), ] jailed = data.get("jailed_until") if jailed: jail_dt = datetime.datetime.fromisoformat(jailed) if jail_dt > now: ts = int(jail_dt.timestamp()) lines.append(S.COOLDOWNS_UI["jailed"].format(ts=ts)) else: lines.append(S.COOLDOWNS_UI["jail_expired"]) embed = discord.Embed( title=S.TITLE["cooldowns"], description="\n".join(lines), color=0x5865F2, ) await interaction.response.send_message(embed=embed, ephemeral=True) @tree.command(name="jailed", description=S.CMD["jailed"]) @app_commands.guild_only() async def cmd_jailed(interaction: discord.Interaction): await interaction.response.defer() jailed = await economy.do_get_jailed() if not jailed: embed = discord.Embed( title=S.JAILED_UI["title"], description=S.JAILED_UI["empty"], color=0x57F287, ) await interaction.followup.send(embed=embed) return now = datetime.datetime.now(datetime.timezone.utc) lines = [] for uid, remaining in jailed: ts = int((now + remaining).timestamp()) member = interaction.guild.get_member(uid) if interaction.guild else None mention = member.mention if member else f"<@{uid}>" lines.append(S.JAILED_UI["entry"].format(mention=mention, ts=ts)) plural = "" if len(jailed) == 1 else "i" embed = discord.Embed( title=S.JAILED_UI["title"], description="\n".join(lines), color=0xED4245, ) embed.set_footer(text=S.JAILED_UI["footer"].format(count=len(jailed), plural=plural)) await interaction.followup.send(embed=embed) @tree.command(name="rank", description=S.CMD["rank"]) @app_commands.describe(kasutaja=S.OPT["rank_kasutaja"]) async def cmd_rank(interaction: discord.Interaction, kasutaja: discord.Member | None = None): target = kasutaja or interaction.user data = await economy.get_user(target.id) exp = data.get("exp", 0) level = economy.get_level(exp) role_name = economy.level_role_name(level) next_level = level + 1 exp_this = economy.exp_for_level(level) exp_next = economy.exp_for_level(next_level) progress = exp - exp_this needed = exp_next - exp_this pct = progress / needed if needed > 0 else 1.0 filled = int(pct * 12) bar = "█" * filled + "░" * (12 - filled) embed = discord.Embed( title=S.RANK_UI["title"].format(name=target.display_name, level=level), color=0x5865F2, ) embed.add_field(name=S.RANK_UI["field_title"], value=f"**{role_name}**", inline=True) embed.add_field(name=S.RANK_UI["field_exp"], value=str(exp), inline=True) embed.add_field( name=S.RANK_UI["field_progress"].format(next=next_level), value=S.RANK_UI["progress_val"].format(bar=bar, progress=progress, needed=needed), inline=False, ) if level < 10: embed.set_footer(text=S.RANK_UI["footer_t1"]) elif level < 20: embed.set_footer(text=S.RANK_UI["footer_t2"]) else: embed.set_footer(text=S.RANK_UI["footer_t3"]) await interaction.response.send_message(embed=embed, ephemeral=True) if not kasutaja and interaction.guild: member = interaction.guild.get_member(target.id) if member: asyncio.create_task(_ensure_level_role(member, level)) @tree.command(name="stats", description=S.CMD["stats"]) @app_commands.describe(kasutaja=S.OPT["stats_kasutaja"]) async def cmd_stats(interaction: discord.Interaction, kasutaja: discord.Member | None = None): target = kasutaja or interaction.user d = await economy.get_user(target.id) def _s(key: str) -> int: return d.get(key, 0) embed = discord.Embed( title=f"{S.TITLE['stats']} - {target.display_name}", color=0x5865F2, ) embed.add_field( name=S.STATS_UI["economy_field"], value=S.STATS_UI["economy_val"].format(peak=_coin(_s("peak_balance")), earned=_coin(_s("lifetime_earned")), lost=_coin(_s("lifetime_lost"))), inline=True, ) embed.add_field( name=S.STATS_UI["work_field"], value=S.STATS_UI["work_val"].format(work=_s("work_count"), beg=_s("beg_count")), inline=True, ) embed.add_field(name="\u200b", value="\u200b", inline=False) embed.add_field( name=S.STATS_UI["gamble_field"], value=S.STATS_UI["gamble_val"].format(wagered=_coin(_s("total_wagered")), win=_coin(_s("biggest_win")), loss=_coin(_s("biggest_loss")), jackpots=_s("slots_jackpots")), inline=True, ) embed.add_field( name=S.STATS_UI["crime_field"], value=S.STATS_UI["crime_val"].format(crimes=_s("crimes_attempted"), succeeded=_s("crimes_succeeded"), heists=_s("heists_joined"), heists_won=_s("heists_won"), jailed=_s("times_jailed"), bail=_coin(_s("total_bail_paid"))), inline=True, ) embed.add_field(name="\u200b", value="\u200b", inline=False) embed.add_field( name=S.STATS_UI["social_field"], value=S.STATS_UI["social_val"].format(given=_coin(_s("total_given")), received=_coin(_s("total_received"))), inline=True, ) embed.add_field( name=S.STATS_UI["records_field"], value=S.STATS_UI["records_val"].format(streak=_s("best_daily_streak")), inline=True, ) await interaction.response.send_message(embed=embed, ephemeral=True) @tree.command(name="daily", description=S.CMD["daily"]) async def cmd_daily(interaction: discord.Interaction): res = await economy.do_daily(interaction.user.id) if not res["ok"]: if res["reason"] == "banned": await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "cooldown": await interaction.response.send_message( S.CD_MSG["daily"].format(ts=_cd_ts(res["remaining"])), ephemeral=True ) return streak = res["streak"] streak_str = f"🔥 {streak}p" + ( " (+200%)" if res["streak_mult"] >= 3.0 else " (+100%)" if res["streak_mult"] >= 2.0 else " (+50%)" if res["streak_mult"] >= 1.5 else "" ) lines = [S.DAILY_UI["earned"].format(earned=_coin(res["earned"]))] if res["interest"]: lines.append(S.DAILY_UI["interest"].format(interest=_coin(res["interest"]))) if res["vip"]: lines.append(S.DAILY_UI["vip"]) lines.append(S.DAILY_UI["footer"].format(streak_str=streak_str, balance=_coin(res["balance"]))) embed = discord.Embed(title=S.TITLE["daily"], description="\n".join(lines), color=0xF4C430) await interaction.response.send_message(embed=embed) asyncio.create_task(_maybe_remind(interaction.user.id, "daily")) asyncio.create_task(_award_exp(interaction, economy.EXP_REWARDS["daily"])) @tree.command(name="work", description=S.CMD["work"]) async def cmd_work(interaction: discord.Interaction): if await _check_cmd_rate(interaction): return res = await economy.do_work(interaction.user.id) if not res["ok"]: if res["reason"] == "banned": await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "cooldown": await interaction.response.send_message( S.CD_MSG["work"].format(ts=_cd_ts(res["remaining"])), ephemeral=True ) elif res["reason"] == "jailed": await interaction.response.send_message( S.CD_MSG["jailed"].format(ts=_cd_ts(res["remaining"])), ephemeral=True ) return desc = S.WORK_UI["desc"].format(job=res["job"], earned=_coin(res["earned"])) if res["lucky"]: desc += S.WORK_UI["redbull"] if res["hiir"]: desc += S.WORK_UI["hiir"] if res["laud"]: desc += S.WORK_UI["laud"] desc += S.WORK_UI["balance"].format(balance=_coin(res["balance"])) embed = discord.Embed(title=S.TITLE["work"], description=desc, color=0x57F287) await interaction.response.send_message(embed=embed) asyncio.create_task(_maybe_remind(interaction.user.id, "work")) asyncio.create_task(_award_exp(interaction, economy.EXP_REWARDS["work"])) @tree.command(name="beg", description=S.CMD["beg"]) async def cmd_beg(interaction: discord.Interaction): if await _check_cmd_rate(interaction): return res = await economy.do_beg(interaction.user.id) if not res["ok"]: if res["reason"] == "banned": await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "cooldown": await interaction.response.send_message( S.CD_MSG["beg"].format(ts=_cd_ts(res["remaining"])), ephemeral=True ) return if res["jailed"]: title = "🔒 " + S.TITLE["beg"] color = 0xE67E22 else: title = S.TITLE["beg"] color = 0x99AAB5 beg_lines = [S.BEG_UI["desc"].format(text=res["text"], earned=_coin(res["earned"]))] if res["klaviatuur"]: beg_lines.append(S.BEG_UI["klaviatuur"]) beg_lines.append(S.BEG_UI["balance"].format(balance=_coin(res["balance"]))) embed = discord.Embed(title=title, description="\n".join(beg_lines), color=color) await interaction.response.send_message(embed=embed) asyncio.create_task(_maybe_remind(interaction.user.id, "beg")) asyncio.create_task(_award_exp(interaction, economy.EXP_REWARDS["beg"])) @tree.command(name="crime", description=S.CMD["crime"]) async def cmd_crime(interaction: discord.Interaction): if await _check_cmd_rate(interaction): return res = await economy.do_crime(interaction.user.id) if not res["ok"]: if res["reason"] == "banned": await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "cooldown": await interaction.response.send_message( S.CD_MSG["crime"].format(ts=_cd_ts(res["remaining"])), ephemeral=True ) elif res["reason"] == "jailed": await interaction.response.send_message( S.CD_MSG["jailed"].format(ts=_cd_ts(res["remaining"])), ephemeral=True ) return if res["success"]: crime_lines = [S.CRIME_UI["win_desc"].format(text=res["text"], earned=_coin(res["earned"]))] if res["mikrofon"]: crime_lines.append(S.CRIME_UI["mikrofon"].lstrip("\n")) crime_lines.append(S.CRIME_UI["balance"].lstrip("\n").format(balance=_coin(res["balance"]))) embed = discord.Embed( title=S.TITLE["crime_win"], description="\n".join(crime_lines), color=0x57F287, ) else: jail_part = S.CRIME_UI["fail_jailed"].format(ts=_cd_ts(economy.JAIL_DURATION)) if res.get("jailed") else S.CRIME_UI["fail_shield"] embed = discord.Embed( title=S.TITLE["crime_fail"], description=S.CRIME_UI["fail_base"].format(text=res["text"], fine=_coin(res["fine"])) + jail_part + S.CRIME_UI["balance"].format(balance=_coin(res["balance"])), color=0xED4245, ) await interaction.response.send_message(embed=embed) asyncio.create_task(_maybe_remind(interaction.user.id, "crime")) if res["success"]: asyncio.create_task(_award_exp(interaction, economy.EXP_REWARDS["crime_win"])) @tree.command(name="rob", description=S.CMD["rob"]) async def cmd_rob(interaction: discord.Interaction, sihtmärk: discord.Member): if await _check_cmd_rate(interaction): return if sihtmärk.id == interaction.user.id: await interaction.response.send_message(S.ERR["rob_self"], ephemeral=True) return if sihtmärk.bot and (bot.user is None or sihtmärk.id != bot.user.id): await interaction.response.send_message(S.ERR["rob_bot"], ephemeral=True) return if bot.user and sihtmärk.id == bot.user.id: await interaction.response.send_message(S.ERR["rob_house_blocked"], ephemeral=True) return res = await economy.do_rob(interaction.user.id, sihtmärk.id) if not res["ok"]: if res["reason"] == "banned": await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "cooldown": await interaction.response.send_message( S.CD_MSG["rob"].format(ts=_cd_ts(res["remaining"])), ephemeral=True ) elif res["reason"] == "jailed": await interaction.response.send_message( S.CD_MSG["jailed"].format(ts=_cd_ts(res["remaining"])), ephemeral=True ) elif res["reason"] == "broke": await interaction.response.send_message( S.ERR["rob_too_poor"].format(name=sihtmärk.display_name), ephemeral=True ) elif res["reason"] == "target_jailed": await interaction.response.send_message( S.ERR["rob_target_jailed"].format(name=sihtmärk.display_name), ephemeral=True ) return if res["success"]: if res.get("jackpot"): desc = S.ROB_UI["jackpot_desc"].format(stolen=_coin(res["stolen"]), balance=_coin(res["balance"])) color = 0xF4C430 else: desc = S.ROB_UI["win_desc"].format(stolen=_coin(res["stolen"]), name=sihtmärk.display_name, balance=_coin(res["balance"])) color = 0x57F287 embed = discord.Embed(title=S.TITLE["rob_win"], description=desc, color=color) elif res["reason"] == "valvur": embed = discord.Embed( title=S.TITLE["rob_anticheat"], description=S.ROB_UI["anticheat_desc"].format(name=sihtmärk.display_name, fine=_coin(res["fine"])), color=0xED4245, ) # Notify target if anticheat fully depleted target_data = await economy.get_user(sihtmärk.id) if "anticheat" not in target_data.get("items", []): try: await sihtmärk.send(S.ROB_UI["anticheat_worn"]) except discord.Forbidden: pass else: embed = discord.Embed( title=S.TITLE["rob_fail"], description=S.ROB_UI["fail_desc"].format(fine=_coin(res["fine"]), balance=_coin(res["balance"])), color=0xED4245, ) await interaction.response.send_message(embed=embed) asyncio.create_task(_maybe_remind(interaction.user.id, "rob")) if res["success"]: asyncio.create_task(_award_exp(interaction, economy.EXP_REWARDS["rob_win"])) # --------------------------------------------------------------------------- # /heist - multiplayer group robbery of the house # --------------------------------------------------------------------------- _HEIST_JOIN_WINDOW = 300 # seconds players have to join _HEIST_MIN_PLAYERS = 2 _HEIST_GLOBAL_CD = 14400 # seconds between heist events server-wide (4h) _HEIST_MAX_PLAYERS = 8 _HEIST_BASE_CHANCE = 0.35 # 35% solo _HEIST_CHANCE_STEP = 0.05 # +5% per extra player _HEIST_MAX_CHANCE = 0.65 # cap at 65% def _build_heist_story(participants: list[discord.Member], success: bool) -> list[str]: """Return a list of story lines for the heist narrative reveal.""" story = S.HEIST_STORY leader = participants[0].display_name member = participants[1].display_name if len(participants) > 1 else participants[0].display_name if len(participants) == 1: names = f"**{leader}**" elif len(participants) == 2: names = S.HEIST_UI["names_duo"].format(a=participants[0].display_name, b=participants[1].display_name) elif len(participants) <= 4: names = S.HEIST_UI["names_sep"].join(f"**{p.display_name}**" for p in participants) else: names = S.HEIST_UI["names_crew"].format(leader=participants[0].display_name) vehicle = random.choice(story["vehicles"]) approach = random.choice(["sneaky", "loud"]) non_leaders = participants[1:] if len(participants) > 1 else participants def fill(tmpl: str) -> str: picked = random.choice(non_leaders).display_name return tmpl.format( leader=f"**{leader}**", member=f"**{picked}**", names=names, vehicle=vehicle, ) getaway_pool = "getaway_success" if success else "getaway_fail" return [ fill(random.choice(story["arrival"])), fill(random.choice(story[f"entry_{approach}"])), fill(random.choice(story["inside"])), fill(random.choice(story["vault"])), fill(random.choice(story["vault_open"])), fill(random.choice(story["police_inbound"])), fill(random.choice(story[getaway_pool])), fill(random.choice(story["escape_success" if success else "escape_fail"])), ] class HeistLobbyView(discord.ui.View): def __init__(self, organizer: discord.Member): super().__init__(timeout=_HEIST_JOIN_WINDOW) self.organizer = organizer self.participants: list[discord.Member] = [organizer] self.message: discord.Message | None = None self.resolved = False def _chance(self) -> float: n = len(self.participants) return min(_HEIST_BASE_CHANCE + _HEIST_CHANCE_STEP * (n - 1), _HEIST_MAX_CHANCE) def _lobby_embed(self) -> discord.Embed: names = "\n".join(f"• {p.display_name}" for p in self.participants) desc = S.HEIST_UI["lobby_desc"].format( n=len(self.participants), max=_HEIST_MAX_PLAYERS, names=names, chance=int(self._chance() * 100), ts=int(self._timeout_expiry()), ) return discord.Embed(title=S.TITLE["heist_lobby"], description=desc, color=0xE67E22) def _timeout_expiry(self) -> float: import time return time.time() + (self.timeout or 0) @discord.ui.button(label=S.HEIST_UI["btn_join"], style=discord.ButtonStyle.danger) async def join(self, interaction: discord.Interaction, _: discord.ui.Button): if any(p.id == interaction.user.id for p in self.participants): await interaction.response.send_message(S.HEIST_UI["already_joined"], ephemeral=True) return if len(self.participants) >= _HEIST_MAX_PLAYERS: await interaction.response.send_message(S.ERR["heist_full"], ephemeral=True) return if interaction.user.id in _active_games: await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True) return res = await economy.do_heist_check(interaction.user.id) if not res["ok"]: if res["reason"] == "banned": await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "jailed": await interaction.response.send_message( S.CD_MSG["jailed"].format(ts=_cd_ts(res["remaining"])), ephemeral=True ) else: await interaction.response.send_message( S.CD_MSG["heist"].format(ts=_cd_ts(res["remaining"])), ephemeral=True ) return self.participants.append(interaction.user) _active_games.add(interaction.user.id) await interaction.response.edit_message(embed=self._lobby_embed()) @discord.ui.button(label=S.HEIST_UI["btn_start"], style=discord.ButtonStyle.success) async def start_now(self, interaction: discord.Interaction, _: discord.ui.Button): if interaction.user.id != self.organizer.id: await interaction.response.send_message(S.HEIST_UI["only_organizer"], ephemeral=True) return if len(self.participants) < _HEIST_MIN_PLAYERS: await interaction.response.send_message( S.ERR["heist_min_players"].format(min=_HEIST_MIN_PLAYERS), ephemeral=True ) return await self._resolve(interaction) async def _resolve(self, interaction: discord.Interaction | None = None) -> None: global _active_heist if self.resolved: return self.resolved = True _active_heist = None self.stop() self.clear_items() for p in self.participants: _active_games.discard(p.id) n = len(self.participants) channel = (interaction.channel if interaction else self.message.channel if self.message else None) if n < _HEIST_MIN_PLAYERS: embed = discord.Embed( title=S.TITLE["heist_cancel"], description=S.HEIST_UI["cancel_desc"].format(min=_HEIST_MIN_PLAYERS), color=0x99AAB5, ) if interaction and not interaction.response.is_done(): await interaction.response.edit_message(embed=embed, view=self) elif self.message: try: await self.message.edit(embed=embed, view=self) except discord.HTTPException: pass return # Pre-roll outcome so story ending matches result success = random.random() < self._chance() story_lines = _build_heist_story(self.participants, success) # Close lobby message - remove buttons, mark as started lobby_done = discord.Embed( title=S.HEIST_UI["started_title"], description=S.HEIST_UI["started_desc"].format(n=n), color=0x99AAB5, ) if interaction and not interaction.response.is_done(): await interaction.response.edit_message(embed=lobby_done, view=self) elif self.message: try: await self.message.edit(embed=lobby_done, view=self) except discord.HTTPException: pass # Send story message and reveal line by line if channel: story_embed = discord.Embed(title=S.HEIST_UI["story_title"], description="", color=0xE67E22) story_msg = await channel.send(embed=story_embed) accumulated = "" for i, line in enumerate(story_lines): await asyncio.sleep(random.uniform(3.0, 4.5)) accumulated += ("\n\n" if i > 0 else "") + line story_embed.description = accumulated try: await story_msg.edit(embed=story_embed) except discord.HTTPException: pass await asyncio.sleep(2.0) # Apply economy changes res = await economy.do_heist_resolve([p.id for p in self.participants], success) payout_each = res["payout_each"] names_str = "\n".join(f"• {p.display_name}" for p in self.participants) guild = (interaction.guild if interaction else self.message.guild if self.message else None) if success: result_desc = S.HEIST_UI["win_desc"].format(names=names_str, payout=_coin(payout_each)) result_embed = discord.Embed( title=S.TITLE["heist_win"], description=result_desc, color=0x57F287 ) for p in self.participants: exp_res = await economy.award_exp(p.id, economy.EXP_REWARDS["heist_win"]) if exp_res["old_level"] != exp_res["new_level"] and guild: gm = guild.get_member(p.id) if gm: asyncio.create_task(_ensure_level_role(gm, exp_res["new_level"])) else: result_desc = S.HEIST_UI["fail_desc"].format(names=names_str) result_embed = discord.Embed( title=S.TITLE["heist_fail"], description=result_desc, color=0xED4245 ) # Set global server cooldown (persisted to PocketBase via house record) await economy.set_heist_global_cd(time.time() + _HEIST_GLOBAL_CD) # Post result as a NEW message so it appears at the bottom of the channel if channel: await channel.send(embed=result_embed) elif self.message: try: await self.message.channel.send(embed=result_embed) except discord.HTTPException: pass async def on_timeout(self) -> None: await self._resolve() @tree.command(name="heist", description=S.CMD["heist"]) @app_commands.guild_only() async def cmd_heist(interaction: discord.Interaction): global _active_heist if _active_heist is not None: await interaction.response.send_message(S.ERR["heist_active"], ephemeral=True) return _heist_cd = await economy.get_heist_global_cd() if time.time() < _heist_cd: await interaction.response.send_message( S.CD_MSG["heist_global"].format(ts=_cd_ts(datetime.timedelta(seconds=_heist_cd - time.time()))), ephemeral=True, ) return if interaction.user.id in _active_games: await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True) return res = await economy.do_heist_check(interaction.user.id) if not res["ok"]: if res["reason"] == "banned": await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "jailed": await interaction.response.send_message( S.CD_MSG["jailed"].format(ts=_cd_ts(res["remaining"])), ephemeral=True ) return view = HeistLobbyView(interaction.user) _active_heist = view _active_games.add(interaction.user.id) await interaction.response.send_message(embed=view._lobby_embed(), view=view) view.message = await interaction.original_response() # --------------------------------------------------------------------------- # /jailbreak - Monopoly-style dice escape # --------------------------------------------------------------------------- _DICE_EMOJI = [ "<:TipiYKS:1483103190491856916>", "<:TipiKAKS:1483103215841972404>", "<:TipiKOLM:1483103217846980781>", "<:TipiNELI:1483103237585240114>", "<:TipiVIIS:1483103239036469289>", "<:TipiKUUS:1483103253163020348>", ] class JailbreakView(discord.ui.View): MAX_TRIES = 3 def __init__(self, user_id: int): super().__init__(timeout=120) self.user_id = user_id self.tries = 0 self._die1: int | None = None self._refresh() def _refresh(self): self.clear_items() if self._die1 is None: label = S.JAILBREAK_UI["btn_die1"].format(try_=self.tries + 1, max=self.MAX_TRIES) else: label = S.JAILBREAK_UI["btn_die2"] btn = discord.ui.Button(label=label, style=discord.ButtonStyle.primary) btn.callback = self._on_roll self.add_item(btn) async def _on_roll(self, interaction: discord.Interaction): if interaction.user.id != self.user_id: await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) return if self._die1 is None: self._die1 = random.randint(1, 6) e1 = _DICE_EMOJI[self._die1 - 1] self._refresh() embed = discord.Embed( title=S.TITLE["jailbreak"], description=S.JAILBREAK_UI["die1_desc"].format(die=e1), color=0xF4C430, ) await interaction.response.edit_message(embed=embed, view=self) else: d1, d2 = self._die1, random.randint(1, 6) e1, e2 = _DICE_EMOJI[d1 - 1], _DICE_EMOJI[d2 - 1] double = d1 == d2 self._die1 = None self.tries += 1 tries_left = self.MAX_TRIES - self.tries if double: await economy.do_jail_free(self.user_id) self.clear_items() embed = discord.Embed( title=S.TITLE["jailbreak_free"], description=S.JAILBREAK_UI["free_desc"].format(d1=e1, d2=e2), color=0x57F287, ) await interaction.response.edit_message(embed=embed, view=self) self.stop() elif tries_left == 0: self.stop() user_data = await economy.get_user(self.user_id) bal = user_data["balance"] if bal >= economy.MIN_BAIL: min_fine = max(economy.MIN_BAIL, int(bal * 0.20)) max_fine = max(economy.MIN_BAIL, int(bal * 0.30)) desc = S.JAILBREAK_UI["fail_bail_offer"].format( d1=e1, d2=e2, min=_coin(min_fine), max=_coin(max_fine), bal=_coin(bal) ) embed = discord.Embed(title=S.TITLE["jailbreak_fail"], description=desc, color=0xED4245) await interaction.response.edit_message(embed=embed, view=BailView(self.user_id)) else: embed = discord.Embed( title=S.TITLE["jailbreak_fail"], description=S.JAILBREAK_UI["fail_broke_desc"].format(d1=e1, d2=e2, balance=_coin(bal)), color=0xED4245, ) await interaction.response.edit_message(embed=embed, view=None) else: self._refresh() embed = discord.Embed( title=S.TITLE["jailbreak_miss"].format(tries=self.tries, max=self.MAX_TRIES), description=S.JAILBREAK_UI["miss_desc"].format(d1=e1, d2=e2, left=tries_left), color=0xF4C430, ) await interaction.response.edit_message(embed=embed, view=self) class BailView(discord.ui.View): def __init__(self, user_id: int): super().__init__(timeout=60) self.user_id = user_id @discord.ui.button(label=S.JAILBREAK_UI["bail_btn"], style=discord.ButtonStyle.danger) async def pay_bail(self, interaction: discord.Interaction, _: discord.ui.Button): if interaction.user.id != self.user_id: await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) return res = await economy.do_bail(self.user_id) self.clear_items() self.stop() if not res["ok"] and res.get("reason") == "broke": embed = discord.Embed( title=S.TITLE["jailbreak_bail"], description=S.JAILBREAK_UI["bail_broke_desc"].format(min=_coin(economy.MIN_BAIL), balance=_coin(res["balance"])), color=0xED4245, ) else: embed = discord.Embed( title=S.TITLE["jailbreak_bail"], description=S.JAILBREAK_UI["bail_paid_desc"].format(fine=_coin(res["fine"]), balance=_coin(res["balance"])), color=0x57F287, ) await interaction.response.edit_message(embed=embed, view=self) @tree.command(name="jailbreak", description=S.CMD["jailbreak"]) async def cmd_jailbreak(interaction: discord.Interaction): user_data = await economy.get_user(interaction.user.id) remaining = economy._is_jailed(user_data) if not remaining: await interaction.response.send_message(S.ERR["not_jailed"], ephemeral=True) return if user_data.get("jailbreak_used", False): bal = user_data["balance"] min_fine = max(economy.MIN_BAIL, int(bal * 0.20)) max_fine = max(economy.MIN_BAIL, int(bal * 0.30)) if bal >= economy.MIN_BAIL: desc = S.JAILBREAK_UI["already_bail"].format(min=_coin(min_fine), max=_coin(max_fine), bal=_coin(bal), ts=_cd_ts(remaining)) await interaction.response.send_message( embed=discord.Embed(title=S.TITLE["jailbreak_bail"], description=desc, color=0xED4245), view=BailView(interaction.user.id), ephemeral=True, ) else: desc = S.JAILBREAK_UI["already_broke"].format(min=_coin(economy.MIN_BAIL), bal=_coin(bal), ts=_cd_ts(remaining)) await interaction.response.send_message( embed=discord.Embed(title=S.TITLE["jailbreak_bail"], description=desc, color=0xED4245), ephemeral=True, ) return await economy.set_jailbreak_used(interaction.user.id) embed = discord.Embed( title=S.TITLE["jailbreak"], description=S.JAILBREAK_UI["intro_desc"].format(ts=_cd_ts(remaining), tries=JailbreakView.MAX_TRIES), color=0xF4C430, ) await interaction.response.send_message( embed=embed, view=JailbreakView(interaction.user.id), ephemeral=True ) # --------------------------------------------------------------------------- # /roulette animation # --------------------------------------------------------------------------- _ROULETTE_R = "\U0001f534" # 🔴 _ROULETTE_B = "\u26ab" # ⚫ _ROULETTE_G = "\U0001f7e2" # 🟢 # delays between frames, fast → slow (12 transitions = 13 total viewport positions) _ROULETTE_WHEEL_DELAYS = [0.15, 0.15, 0.18, 0.20, 0.22, 0.25, 0.28, 0.35, 0.45, 0.60, 0.80, 1.00] def _build_roulette_strip(result_emoji: str) -> list[str]: """Build a 17-symbol wheel strip obeying strict transition rules: R → B or G (R can go to either) B → R (B must go to R) G → B (G must go to B) Result is at strip[14] = center of the final viewport. Prefix (0-13) is generated backward from the result; suffix (15-16) is generated forward from the result. Greens appear randomly in the prefix as near-miss elements (up to 2). """ R, B, G = _ROULETTE_R, _ROULETTE_B, _ROULETTE_G strip: list[str] = [None] * 17 # type: ignore[list-item] # ── Suffix: positions 15-16 (deterministic, no greens after result) ── strip[14] = result_emoji strip[15] = R if result_emoji == B else B # B→R, R→B, G→B strip[16] = B if strip[15] == R else R # R→B, B→R # ── Prefix: positions 0-13 built backward from result ── # Inverse transitions: pred(R)=B, pred(B)=R or G, pred(G)=R # First pass: collect positions where a green is valid (cur == B, with room for pred). # Green is only relevant when result is not green itself. green_pos: int | None = None if result_emoji != G: candidates: list[int] = [] cur = result_emoji for i in range(13, -1, -1): if cur == B and 2 <= i <= 11: candidates.append(i) cur = B if cur == R else (R if cur == G else R) if candidates: green_pos = random.choice(candidates) # Second pass: generate strip, inserting green at the chosen position. cur = result_emoji for i in range(13, -1, -1): if cur == R: strip[i] = B elif cur == G: strip[i] = R else: # cur == B strip[i] = G if i == green_pos else R cur = strip[i] return strip def _roulette_frame_embed(symbols: list[str], stopped: bool = False) -> discord.Embed: title = S.ROULETTE["spin_stop"] if stopped else S.ROULETTE["spin_title"] desc = S.ROULETTE["spin_strip"].format( s0=symbols[0], s1=symbols[1], s2=symbols[2], s3=symbols[3], s4=symbols[4] ) return discord.Embed(title=title, description=desc, color=0x99AAB5) @tree.command(name="roulette", description=S.CMD["roulette"]) @app_commands.describe(panus=S.OPT["roulette_panus"], värv=S.OPT["roulette_värv"]) @app_commands.choices(värv=[ app_commands.Choice(name="🔴 Punane", value="punane"), app_commands.Choice(name="⚫ Must", value="must"), app_commands.Choice(name="🟢 Roheline", value="roheline"), ]) async def cmd_roulette(interaction: discord.Interaction, panus: str, värv: app_commands.Choice[str]): _data = await economy.get_user(interaction.user.id) panus_int, _err = _parse_amount(panus, _data["balance"]) if _err: await interaction.response.send_message(_err, ephemeral=True) return if panus_int <= 0: await interaction.response.send_message(S.ERR["positive_bet"], ephemeral=True) return if interaction.user.id in _active_games: await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True) return _active_games.add(interaction.user.id) res = await economy.do_roulette(interaction.user.id, panus_int, värv.value) if not res["ok"]: _active_games.discard(interaction.user.id) if res["reason"] == "banned": await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "jailed": await interaction.response.send_message( S.CD_MSG["jailed"].format(ts=_cd_ts(res["remaining"])), ephemeral=True ) else: await interaction.response.send_message( S.ERR["broke"].format(bal=_coin(_data["balance"])), ephemeral=True ) return # ── Spin animation ──────────────────────────────────────────────────── result_emoji = S.ROULETTE["emoji"][res["result"]] strip = _build_roulette_strip(result_emoji) try: await interaction.response.send_message(embed=_roulette_frame_embed(strip[0:5])) spin_msg = await interaction.original_response() for i, delay in enumerate(_ROULETTE_WHEEL_DELAYS, 1): await asyncio.sleep(delay) stopped = i == len(_ROULETTE_WHEEL_DELAYS) await spin_msg.edit(embed=_roulette_frame_embed(strip[i:i + 5], stopped=stopped)) await asyncio.sleep(0.55) # ── Final result embed ──────────────────────────────────────────────── emoji = S.ROULETTE["emoji"].get(res["result"], "🎰") genitive = S.ROULETTE["genitive"].get(res["result"], res["result"]) if res["won"]: mult_str = f" · **{res['mult']}x**" if res["mult"] > 1 else "" embed = discord.Embed( title=S.ROULETTE["win_title"].format(emoji=emoji), description=S.ROULETTE["win_desc"].format( genitive=genitive, mult=mult_str, change=_coin(res["change"]), balance=_coin(res["balance"]), ), color=0x57F287, ) asyncio.create_task(_award_exp(interaction, economy.gamble_exp(panus_int))) else: embed = discord.Embed( title=S.ROULETTE["lose_title"].format(emoji=emoji), description=S.ROULETTE["lose_desc"].format( genitive=genitive, change=_coin(abs(res["change"])), balance=_coin(res["balance"]), ), color=0xED4245, ) await spin_msg.edit(embed=embed) finally: _active_games.discard(interaction.user.id) @tree.command(name="give", description=S.CMD["give"]) @app_commands.describe(kasutaja=S.OPT["give_kasutaja"], summa=S.OPT["give_summa"]) async def cmd_give(interaction: discord.Interaction, kasutaja: discord.Member, summa: str): _data = await economy.get_user(interaction.user.id) summa_int, _err = _parse_amount(summa, _data["balance"]) if _err: await interaction.response.send_message(_err, ephemeral=True) return if summa_int <= 0: await interaction.response.send_message(S.ERR["positive_amount"], ephemeral=True) return if kasutaja.id == interaction.user.id: await interaction.response.send_message(S.ERR["give_self"], ephemeral=True) return if kasutaja.bot: await interaction.response.send_message(S.ERR["give_bot"], ephemeral=True) return res = await economy.do_give(interaction.user.id, kasutaja.id, summa_int) if not res["ok"]: if res["reason"] == "banned": await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "jailed": await interaction.response.send_message(S.ERR["give_jailed"].format(ts=_cd_ts(res["remaining"])), ephemeral=True) else: await interaction.response.send_message( S.ERR["broke"].format(bal=_coin(_data["balance"])), ephemeral=True ) return embed = discord.Embed( title=f"{economy.COIN} {S.TITLE['give']}", description=S.GIVE_UI["desc"].format(giver=interaction.user.display_name, amount=_coin(summa_int), receiver=kasutaja.display_name), color=0xF4C430, ) await interaction.response.send_message(embed=embed) class LeaderboardView(discord.ui.View): PER_PAGE = 10 def __init__( self, regular: list[tuple[str, int]], house_entry: tuple[str, int] | None, guild: discord.Guild | None, bot_user: discord.ClientUser | None, exp_entries: list[tuple[str, int, int]] | None = None, ): super().__init__(timeout=120) self.regular = regular self.house_entry = house_entry self.guild = guild self.bot_user = bot_user self.exp_entries = exp_entries or [] self.page = 0 self.mode = "coins" # "coins" or "exp" self.max_page = max(0, (len(regular) - 1) // self.PER_PAGE) if regular else 0 self._update_buttons() def _current_list(self): return self.regular if self.mode == "coins" else self.exp_entries def _update_buttons(self): current = self._current_list() self.max_page = max(0, (len(current) - 1) // self.PER_PAGE) if current else 0 self.prev_btn.disabled = self.page == 0 self.next_btn.disabled = self.page >= self.max_page self.coins_btn.style = discord.ButtonStyle.primary if self.mode == "coins" else discord.ButtonStyle.secondary self.exp_btn.style = discord.ButtonStyle.primary if self.mode == "exp" else discord.ButtonStyle.secondary def _make_embed(self, highlight_uid: int | None = None) -> discord.Embed: if self.mode == "coins": embed = discord.Embed(title=f"{economy.COIN} {S.TITLE['leaderboard_coins']}", color=0xF4C430) else: embed = discord.Embed(title=S.TITLE["leaderboard_exp"], color=0x5865F2) lines = [] if self.mode == "coins" and self.page == 0 and self.house_entry: _, bal = self.house_entry house_name = self.bot_user.display_name if self.bot_user else "TipiBOT" lines.append(S.LEADERBOARD_UI["house_entry"].format(name=house_name, balance=_coin(bal))) lines.append("") start = self.page * self.PER_PAGE medals = ["🥇", "🥈", "🥉"] current = self._current_list() slice_ = current[start:start + self.PER_PAGE] if not slice_: lines.append(S.LEADERBOARD_UI["no_entries"]) else: for i, entry in enumerate(slice_): rank = start + i uid = entry[0] prefix = medals[rank] if rank < 3 else f"**{rank + 1}.**" if self.guild: member = self.guild.get_member(int(uid)) name = member.display_name if member else S.LEADERBOARD_UI["unknown_user"].format(uid=uid) else: name = S.LEADERBOARD_UI["unknown_user"].format(uid=uid) if highlight_uid and int(uid) == highlight_uid: name = f"**› {name} ‹**" if self.mode == "coins": lines.append(f"{prefix} {name} - {_coin(entry[1])}") else: lines.append(S.LEADERBOARD_UI["exp_entry"].format(prefix=prefix, name=name, exp=entry[1], level=entry[2])) total = self.max_page + 1 embed.description = "\n".join(lines) embed.set_footer(text=S.LEADERBOARD_UI["footer"].format(page=self.page + 1, total=total, count=len(current))) return embed @discord.ui.button(label="◄", style=discord.ButtonStyle.secondary) async def prev_btn(self, interaction: discord.Interaction, button: discord.ui.Button): self.page -= 1 self._update_buttons() await interaction.response.edit_message(embed=self._make_embed(), view=self) @discord.ui.button(label="►", style=discord.ButtonStyle.secondary) async def next_btn(self, interaction: discord.Interaction, button: discord.ui.Button): self.page += 1 self._update_buttons() await interaction.response.edit_message(embed=self._make_embed(), view=self) @discord.ui.button(label=S.LEADERBOARD_UI["btn_coins"], style=discord.ButtonStyle.primary) async def coins_btn(self, interaction: discord.Interaction, button: discord.ui.Button): self.mode = "coins" self.page = 0 self._update_buttons() await interaction.response.edit_message(embed=self._make_embed(), view=self) @discord.ui.button(label=S.LEADERBOARD_UI["btn_exp"], style=discord.ButtonStyle.secondary) async def exp_btn(self, interaction: discord.Interaction, button: discord.ui.Button): self.mode = "exp" self.page = 0 self._update_buttons() await interaction.response.edit_message(embed=self._make_embed(), view=self) @discord.ui.button(label=S.LEADERBOARD_UI["btn_find_me"], style=discord.ButtonStyle.secondary) async def find_me_btn(self, interaction: discord.Interaction, button: discord.ui.Button): uid = interaction.user.id for i, entry in enumerate(self._current_list()): if int(entry[0]) == uid: self.page = i // self.PER_PAGE self._update_buttons() await interaction.response.edit_message( embed=self._make_embed(highlight_uid=uid), view=self ) return await interaction.response.send_message( S.ERR["not_in_leaderboard"], ephemeral=True ) async def on_timeout(self): for child in self.children: child.disabled = True @tree.command(name="leaderboard", description=S.CMD["leaderboard"]) async def cmd_leaderboard(interaction: discord.Interaction): await interaction.response.defer() all_entries = await economy.get_leaderboard(top_n=None) exp_entries_raw = await economy.get_leaderboard_exp(top_n=None) house_entry = None regular = [] for uid, bal in all_entries: if bot.user and int(uid) == bot.user.id: house_entry = (uid, bal) else: regular.append((uid, bal)) exp_entries = [e for e in exp_entries_raw if not (bot.user and int(e[0]) == bot.user.id)] view = LeaderboardView(regular, house_entry, interaction.guild, bot.user, exp_entries) await interaction.followup.send(embed=view._make_embed(), view=view) def _shop_embed(tier: int, user_data: dict) -> discord.Embed: owned = set(user_data.get("items", [])) item_uses = user_data.get("item_uses", {}) _tier_names = {1: S.SHOP_UI["tier_1"], 2: S.SHOP_UI["tier_2"], 3: S.SHOP_UI["tier_3"]} embed = discord.Embed( title=f"{economy.COIN} TipiBOTi pood · {_tier_names[tier]}", description=S.SHOP_UI["desc"].format(bal=_coin(user_data["balance"])), color=[0x57F287, 0xF4C430, 0xED4245][tier - 1], ) for item_id in sorted(economy.SHOP_TIERS[tier], key=lambda k: economy.SHOP[k]["cost"]): item = economy.SHOP[item_id] anticheat_uses = item_uses.get("anticheat", 0) if item_id == "anticheat" else 0 min_lvl = economy.SHOP_LEVEL_REQ.get(item_id, 0) user_lvl = economy.get_level(user_data.get("exp", 0)) if item_id in owned and not (item_id == "anticheat" and anticheat_uses <= 0): if item_id == "anticheat": _key = "owned_uses_1" if anticheat_uses == 1 else "owned_uses_n" status = S.SHOP_UI[_key].format(uses=anticheat_uses) else: status = S.SHOP_UI["owned"] elif min_lvl > 0 and user_lvl < min_lvl: status = S.SHOP_UI["locked"].format(min_lvl=min_lvl, user_lvl=user_lvl) else: status = f"{item['cost']} {economy.COIN}" embed.add_field( name=f"{item['emoji']} {item['name']} · {status}", value=item["description"], inline=False, ) return embed class ShopView(discord.ui.View): def __init__(self, user_data: dict, tier: int = 1): super().__init__(timeout=120) self._user_data = user_data self._tier = tier self._update_buttons() def _update_buttons(self): self.clear_items() for t, label in [(1, S.SHOP_BTN[1]), (2, S.SHOP_BTN[2]), (3, S.SHOP_BTN[3])]: btn = discord.ui.Button( label=label, style=discord.ButtonStyle.primary if t == self._tier else discord.ButtonStyle.secondary, custom_id=f"shop_tier_{t}", ) btn.callback = self._make_callback(t) self.add_item(btn) def _make_callback(self, tier: int): async def callback(interaction: discord.Interaction): self._tier = tier self._update_buttons() self._user_data = await economy.get_user(interaction.user.id) await interaction.response.edit_message( embed=_shop_embed(self._tier, self._user_data), view=self ) return callback @tree.command(name="shop", description=S.CMD["shop"]) async def cmd_shop(interaction: discord.Interaction): data = await economy.get_user(interaction.user.id) await interaction.response.send_message( embed=_shop_embed(1, data), view=ShopView(data, tier=1), ephemeral=True ) @tree.command(name="buy", description=S.CMD["buy"]) @app_commands.describe(ese=S.OPT["buy_ese"]) @app_commands.choices(ese=[ app_commands.Choice(name=f"{v['name']} ({v['cost']} TipiCOINi)", value=k) for k, v in economy.SHOP.items() ]) async def cmd_buy(interaction: discord.Interaction, ese: app_commands.Choice[str]): res = await economy.do_buy(interaction.user.id, ese.value) if not res["ok"]: if res["reason"] == "banned": await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "owned": await interaction.response.send_message(S.ERR["item_owned"], ephemeral=True) elif res["reason"] == "level_required": await interaction.response.send_message( S.ERR["item_level_req"].format(min_level=res["min_level"], user_level=res["user_level"]), ephemeral=True, ) elif res["reason"] == "insufficient": await interaction.response.send_message( S.ERR["broke_need"].format(need=_coin(res["need"])), ephemeral=True ) else: await interaction.response.send_message(S.ERR["item_not_found"], ephemeral=True) return item = res["item"] embed = discord.Embed( title=S.BUY_UI["title"].format(emoji=item["emoji"], name=item["name"]), description=S.BUY_UI["desc"].format(description=item["description"], balance=_coin(res["balance"])), color=0x57F287, ) await interaction.response.send_message(embed=embed) # --------------------------------------------------------------------------- # Rock Paper Scissors (vs Bot OR PvP) # --------------------------------------------------------------------------- _RPS_CHOICES = S.RPS_CHOICES _RPS_BEATS = {"🪨": "✂️", "📄": "🪨", "✂️": "📄"} # ── Single-player (vs bot) ────────────────────────────────────────────────── class RPSView(discord.ui.View): def __init__(self, challenger: discord.User, bet: int = 0): super().__init__(timeout=60) self.challenger = challenger self.bet = bet async def _resolve(self, interaction: discord.Interaction, player_pick: str): if interaction.user.id != self.challenger.id: await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) return self.stop() _active_games.discard(self.challenger.id) bot_pick = random.choice(list(_RPS_CHOICES)) p_name = _RPS_CHOICES[player_pick] b_name = _RPS_CHOICES[bot_pick] if player_pick == bot_pick: outcome, result, color = "tie", S.RPS_UI["result_tie"], 0x99AAB5 elif _RPS_BEATS[player_pick] == bot_pick: outcome, result, color = "win", S.RPS_UI["result_win"], 0x57F287 else: outcome, result, color = "lose", S.RPS_UI["result_lose"], 0xED4245 bet_line = "" if self.bet > 0: res = await economy.do_game_bet(interaction.user.id, self.bet, outcome) if outcome == "win": bet_line = S.RPS_UI["bet_win"].format(amount=_coin(self.bet), balance=_coin(res["balance"])) elif outcome == "lose": bet_line = S.RPS_UI["bet_lose"].format(amount=_coin(self.bet), balance=_coin(res["balance"])) else: bet_line = S.RPS_UI["bet_tie"].format(balance=_coin(res["balance"])) embed = discord.Embed( title=S.TITLE["rps"], description=S.RPS_UI["result_desc"].format(player_pick=player_pick, player_name=p_name, bot_pick=bot_pick, bot_name=b_name, result=result, bet_line=bet_line), color=color, ) await interaction.response.edit_message(embed=embed, view=None) @discord.ui.button(label=S.RPS_UI["btn_rock"], style=discord.ButtonStyle.secondary) async def pick_rock(self, interaction: discord.Interaction, _: discord.ui.Button): await self._resolve(interaction, "🪨") @discord.ui.button(label=S.RPS_UI["btn_paper"], style=discord.ButtonStyle.secondary) async def pick_paper(self, interaction: discord.Interaction, _: discord.ui.Button): await self._resolve(interaction, "📄") @discord.ui.button(label=S.RPS_UI["btn_scissors"], style=discord.ButtonStyle.secondary) async def pick_scissors(self, interaction: discord.Interaction, _: discord.ui.Button): await self._resolve(interaction, "✂️") async def on_timeout(self): _active_games.discard(self.challenger.id) for item in self.children: item.disabled = True # ── PvP ───────────────────────────────────────────────────────────────────── class RpsGame: """Shared mutable state for a PvP RPS match.""" def __init__(self, player_a: discord.Member, player_b: discord.Member, bet: int): self.player_a = player_a self.player_b = player_b self.bet = bet self.choice_a: str | None = None self.choice_b: str | None = None self.dm_msg_a: discord.Message | None = None self.dm_msg_b: discord.Message | None = None self.server_message: discord.Message | None = None self._resolved = False self._lock = asyncio.Lock() async def maybe_resolve(self) -> None: async with self._lock: if self._resolved or self.choice_a is None or self.choice_b is None: return self._resolved = True a, b = self.choice_a, self.choice_b if a == b: winner, color = None, 0x99AAB5 result_a = result_b = S.RPS_UI["result_tie"] elif _RPS_BEATS[a] == b: winner, color = "a", 0x57F287 result_a = S.RPS_UI["result_win"] result_b = f"❌ {self.player_a.display_name} võitis." else: winner, color = "b", 0xED4245 result_a = f"❌ {self.player_b.display_name} võitis." result_b = S.RPS_UI["result_win"] bet_line_a = bet_line_b = "" if self.bet > 0: if winner == "a": res = await economy.do_give(self.player_b.id, self.player_a.id, self.bet) elif winner == "b": res = await economy.do_give(self.player_a.id, self.player_b.id, self.bet) else: res = {"ok": True} if self.bet > 0 and winner is not None: if res.get("ok"): bet_line_a = f"\n{'+' if winner == 'a' else '-'}{_coin(self.bet)}" bet_line_b = f"\n{'+' if winner == 'b' else '-'}{_coin(self.bet)}" else: bet_line_a = bet_line_b = S.RPS_UI["duel_broke"] data_a = await economy.get_user(self.player_a.id) data_b = await economy.get_user(self.player_b.id) bal_a, bal_b = data_a["balance"], data_b["balance"] if self.dm_msg_a: await self.dm_msg_a.edit( content=S.RPS_UI["duel_result_a"].format( opponent=self.player_b.display_name, pick_a=a, name_a=_RPS_CHOICES[a], pick_b=b, name_b=_RPS_CHOICES[b], result=result_a, bet_line=bet_line_a, balance=_coin(bal_a) ), view=None, ) if self.dm_msg_b: await self.dm_msg_b.edit( content=S.RPS_UI["duel_result_a"].format( opponent=self.player_a.display_name, pick_a=b, name_a=_RPS_CHOICES[b], pick_b=a, name_b=_RPS_CHOICES[a], result=result_b, bet_line=bet_line_b, balance=_coin(bal_b) ), view=None, ) if self.server_message: if winner == "a": verdict = S.RPS_UI["duel_verdict_win"].format(name=self.player_a.display_name) elif winner == "b": verdict = S.RPS_UI["duel_verdict_win"].format(name=self.player_b.display_name) else: verdict = S.RPS_UI["duel_verdict_tie"] embed = discord.Embed( title=S.TITLE["rps_duel_done"], description=S.RPS_UI["duel_done_desc"].format( a=self.player_a.mention, pick_a=a, pick_b=b, b=self.player_b.mention, verdict=verdict, name_a=self.player_a.display_name, bal_a=_coin(bal_a), name_b=self.player_b.display_name, bal_b=_coin(bal_b) ), color=color, ) await self.server_message.edit(embed=embed, view=None) _active_games.discard(self.player_a.id) _active_games.discard(self.player_b.id) class RpsDmView(discord.ui.View): """DM view for each player to make their pick in a PvP match.""" def __init__(self, game: RpsGame, side: str): super().__init__(timeout=120) self.game = game self.side = side async def _pick(self, interaction: discord.Interaction, choice: str) -> None: if self.side == "a": self.game.choice_a = choice else: self.game.choice_b = choice for item in self.children: item.disabled = True self.stop() await interaction.response.edit_message( content=S.RPS_UI["duel_waiting"].format(choice=choice, name=_RPS_CHOICES[choice]), view=self, ) await self.game.maybe_resolve() async def on_timeout(self) -> None: async with self.game._lock: if self.game._resolved: return self.game._resolved = True _active_games.discard(self.game.player_a.id) _active_games.discard(self.game.player_b.id) for item in self.children: item.disabled = True for player in (self.game.player_a, self.game.player_b): try: await player.send(S.RPS_UI["duel_expire_dm"]) except discord.Forbidden: pass if self.game.server_message: embed = discord.Embed( title=S.TITLE["rps_duel_expire"], description=S.RPS_UI["duel_expire_desc"].format(a=self.game.player_a.mention, b=self.game.player_b.mention), color=0x99AAB5, ) await self.game.server_message.edit(embed=embed, view=None) @discord.ui.button(label=S.RPS_UI["btn_rock"], style=discord.ButtonStyle.secondary) async def pick_rock(self, interaction: discord.Interaction, _: discord.ui.Button): await self._pick(interaction, "🪨") @discord.ui.button(label=S.RPS_UI["btn_paper"], style=discord.ButtonStyle.secondary) async def pick_paper(self, interaction: discord.Interaction, _: discord.ui.Button): await self._pick(interaction, "📄") @discord.ui.button(label=S.RPS_UI["btn_scissors"], style=discord.ButtonStyle.secondary) async def pick_scissors(self, interaction: discord.Interaction, _: discord.ui.Button): await self._pick(interaction, "✂️") class RpsChallengeView(discord.ui.View): """Server-side accept/decline view for PvP RPS challenge.""" def __init__(self, game: RpsGame): super().__init__(timeout=60) self.game = game def _disable_all(self) -> None: for item in self.children: item.disabled = True @discord.ui.button(label=S.RPS_UI["btn_accept"], style=discord.ButtonStyle.success) async def accept(self, interaction: discord.Interaction, _: discord.ui.Button): if interaction.user.id != self.game.player_b.id: await interaction.response.send_message(S.ERR["not_your_challenge"], ephemeral=True) return if self.game.player_b.id in _active_games: await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True) return self.stop() self._disable_all() _active_games.add(self.game.player_b.id) if self.game.bet > 0: data_a = await economy.get_user(self.game.player_a.id) data_b = await economy.get_user(self.game.player_b.id) for player, data in ((self.game.player_a, data_a), (self.game.player_b, data_b)): if data["balance"] < self.game.bet: embed = discord.Embed( title=S.TITLE["rps_duel_cancel"], description=S.RPS_UI["duel_insufficient"].format(mention=player.mention), color=0xED4245, ) await interaction.response.edit_message(embed=embed, view=None) async with self.game._lock: self.game._resolved = True _active_games.discard(self.game.player_a.id) _active_games.discard(self.game.player_b.id) return bet_str = S.RPS_UI["duel_active_bet"].format(bet=_coin(self.game.bet)) if self.game.bet > 0 else "" embed = discord.Embed( title=S.TITLE["rps_duel_active"], description=S.RPS_UI["duel_active_desc"].format(a=self.game.player_a.mention, b=self.game.player_b.mention, bet=bet_str), color=0x5865F2, ) await interaction.response.edit_message(embed=embed, view=self) bet_dm = S.RPS_UI["duel_dm_bet"].format(bet=_coin(self.game.bet)) if self.game.bet > 0 else "" dm_failed: list[str] = [] for player, side in ((self.game.player_a, "a"), (self.game.player_b, "b")): view = RpsDmView(self.game, side) opponent = self.game.player_b if side == "a" else self.game.player_a try: msg = await player.send( S.RPS_UI["duel_dm"].format(opponent=opponent.display_name, bet=bet_dm), view=view, ) if side == "a": self.game.dm_msg_a = msg else: self.game.dm_msg_b = msg except discord.Forbidden: dm_failed.append(player.display_name) if dm_failed: async with self.game._lock: self.game._resolved = True _active_games.discard(self.game.player_a.id) _active_games.discard(self.game.player_b.id) embed = discord.Embed( title=S.TITLE["rps_duel_cancel"], description=S.RPS_UI["duel_dm_fail"].format(names=", ".join(dm_failed)), color=0xED4245, ) await self.game.server_message.edit(embed=embed, view=None) @discord.ui.button(label=S.RPS_UI["btn_decline"], style=discord.ButtonStyle.danger) async def decline(self, interaction: discord.Interaction, _: discord.ui.Button): if interaction.user.id != self.game.player_b.id: await interaction.response.send_message(S.ERR["not_your_challenge"], ephemeral=True) return self.stop() self._disable_all() _active_games.discard(self.game.player_a.id) embed = discord.Embed( title=S.TITLE["rps_duel_decline"], description=S.RPS_UI["duel_decline"].format(name=self.game.player_b.display_name), color=0xED4245, ) await interaction.response.edit_message(embed=embed, view=self) async def on_timeout(self) -> None: _active_games.discard(self.game.player_a.id) self._disable_all() if self.game.server_message: embed = discord.Embed( title=S.TITLE["rps_duel_expire"], description=S.RPS_UI["duel_no_answer"].format(name=self.game.player_b.display_name), color=0x99AAB5, ) await self.game.server_message.edit(embed=embed, view=self) @tree.command(name="rps", description=S.CMD["rps"]) @app_commands.describe(panus=S.OPT["rps_panus"], vastane=S.OPT["rps_vastane"]) async def cmd_rps(interaction: discord.Interaction, panus: str = "0", vastane: discord.Member | None = None): _data = await economy.get_user(interaction.user.id) panus_int, _err = _parse_amount(panus, _data["balance"]) if _err: await interaction.response.send_message(_err, ephemeral=True) return if panus_int < 0: await interaction.response.send_message(S.ERR["positive_bet"], ephemeral=True) return if panus_int > 0: if rem := economy.jailed_remaining(_data): await interaction.response.send_message( S.CD_MSG["jailed"].format(ts=_cd_ts(rem)), ephemeral=True ) return # ── PvP mode ─ if vastane is not None: if interaction.user.id in _active_games: await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True) return if vastane.id == interaction.user.id: await interaction.response.send_message(S.ERR["rps_self"], ephemeral=True) return if vastane.bot: await interaction.response.send_message(S.ERR["rps_bot"], ephemeral=True) return if panus_int > 0 and _data["balance"] < panus_int: await interaction.response.send_message( S.ERR["broke"].format(bal=_coin(_data["balance"])), ephemeral=True ) return game = RpsGame(interaction.user, vastane, panus_int) bet_challenge = S.RPS_UI["challenge_bet"].format(bet=_coin(panus_int)) if panus_int > 0 else "" embed = discord.Embed( title=S.TITLE["rps_duel"], description=S.RPS_UI["challenge_desc"].format(challenger=interaction.user.mention, opponent=vastane.mention, bet=bet_challenge), color=0x5865F2, ) embed.set_footer(text=S.RPS_UI["challenge_footer"]) challenge_view = RpsChallengeView(game) await interaction.response.send_message(embed=embed, view=challenge_view) _active_games.add(interaction.user.id) game.server_message = await interaction.original_response() return # ── vs Bot mode ────────────────────────────────────────────────────── if panus_int > 0 and _data["balance"] < panus_int: await interaction.response.send_message( S.ERR["broke"].format(bal=_coin(_data["balance"])), ephemeral=True ) return if interaction.user.id in _active_games: await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True) return _active_games.add(interaction.user.id) bet_str = S.RPS_UI["vs_bot_bet"].format(bet=_coin(panus_int)) if panus_int > 0 else "" embed = discord.Embed( title=S.TITLE["rps"], description=S.RPS_UI["vs_bot_desc"] + bet_str, color=0x5865F2, ) await interaction.response.send_message(embed=embed, view=RPSView(interaction.user, panus_int)) # --------------------------------------------------------------------------- # /slots # --------------------------------------------------------------------------- _SLOTS_SPIN = "" _SLOTS_DELAY = 0.7 def _slots_embed(r1: str, r2: str, r3: str, title: str = "", # set dynamically color: int = 0x5865F2, footer: str = "") -> discord.Embed: desc = f"{r1} | {r2} | {r3}" if footer: desc += f"\n\n{footer}" return discord.Embed(title=title, description=desc, color=color) @tree.command(name="slots", description=S.CMD["slots"]) @app_commands.describe(panus=S.OPT["slots_panus"]) async def cmd_slots(interaction: discord.Interaction, panus: str): _data = await economy.get_user(interaction.user.id) panus_int, _err = _parse_amount(panus, _data["balance"]) if _err: await interaction.response.send_message(_err, ephemeral=True) return if panus_int <= 0: await interaction.response.send_message(S.ERR["positive_bet"], ephemeral=True) return if interaction.user.id in _active_games: await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True) return _active_games.add(interaction.user.id) res = await economy.do_slots(interaction.user.id, panus_int) if not res["ok"]: _active_games.discard(interaction.user.id) if res["reason"] == "banned": await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) return if res["reason"] == "jailed": await interaction.response.send_message( S.CD_MSG["jailed"].format(ts=_cd_ts(res["remaining"])), ephemeral=True ) return await interaction.response.send_message( S.ERR["broke"].format(bal=_coin(_data["balance"])), ephemeral=True ) return reels = res["reels"] tier = res["tier"] change = res["change"] sp = _SLOTS_SPIN # ── Animated reveal ──────────────────────────────────────────────────── try: await interaction.response.send_message(embed=_slots_embed(sp, sp, sp, title=S.SLOTS_UI["playing"])) msg = await interaction.original_response() await asyncio.sleep(_SLOTS_DELAY) await msg.edit(embed=_slots_embed(reels[0], sp, sp, title=S.SLOTS_UI["playing"])) await asyncio.sleep(_SLOTS_DELAY) await msg.edit(embed=_slots_embed(reels[0], reels[1], sp, title=S.SLOTS_UI["playing"])) await asyncio.sleep(_SLOTS_DELAY) await msg.edit(embed=_slots_embed(reels[0], reels[1], reels[2], title=S.SLOTS_UI["playing"])) await asyncio.sleep(_SLOTS_DELAY * 0.6) # ── Final verdict ───────────────────────────────────────────────────── tier_key = tier if tier in S.SLOTS_TIERS else "miss" title, color = S.SLOTS_TIERS[tier_key] if tier == "jackpot": footer = S.SLOTS_UI["jackpot_footer"].format(change=_coin(change)) elif tier == "triple": footer = S.SLOTS_UI["triple_footer"].format(change=_coin(change)) elif tier == "pair": footer = S.SLOTS_UI["pair_footer"].format(change=_coin(change)) else: footer = S.SLOTS_UI["miss_footer"].format(amount=_coin(panus_int)) footer += S.SLOTS_UI["balance_line"].format(balance=_coin(res["balance"])) await msg.edit(embed=_slots_embed(reels[0], reels[1], reels[2], title=title, color=color, footer=footer)) if tier in ("jackpot", "triple", "pair"): asyncio.create_task(_award_exp(interaction, economy.gamble_exp(panus_int))) finally: _active_games.discard(interaction.user.id) # --------------------------------------------------------------------------- # /blackjack # --------------------------------------------------------------------------- _BJ_RANKS = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"] _BJ_SUITS = ["♠", "♥", "♦", "♣"] _BJ_DEAL_DELAY = 0.65 def _bj_deck() -> list[tuple[str, str]]: deck = [(r, s) for r in _BJ_RANKS for s in _BJ_SUITS] random.shuffle(deck) return deck def _bj_value(hand: list[tuple[str, str]]) -> int: total, aces = 0, 0 for rank, _ in hand: if rank == "A": total += 11 aces += 1 elif rank in ("J", "Q", "K", "10"): total += 10 else: total += int(rank) while total > 21 and aces: total -= 10 aces -= 1 return total def _bj_hand_str(hand: list[tuple[str, str]], hide_second: bool = False) -> str: if hide_second and len(hand) >= 2: return f"`{hand[0][0]}{hand[0][1]}` `🂠`" return " ".join(f"`{r}{s}`" for r, s in hand) def _bj_is_blackjack(hand: list[tuple[str, str]]) -> bool: return len(hand) == 2 and _bj_value(hand) == 21 def _bj_embed( player_hand: list, dealer_hand: list, title: str, color: int, *, hide_dealer: bool = True, doubled_total: int = 0, result_field: tuple | None = None, ) -> discord.Embed: p_str = _bj_hand_str(player_hand) if player_hand else "-" p_val = f" `{_bj_value(player_hand)}`" if player_hand else "" if not dealer_hand: d_str, d_val = "-", "" elif hide_dealer: d_str = _bj_hand_str(dealer_hand, hide_second=True) d_val = f" `{_bj_value([dealer_hand[0]])}`" else: d_str = _bj_hand_str(dealer_hand) d_val = f" `{_bj_value(dealer_hand)}`" desc = f"**{S.BJ_UI['dealer']}:** {d_str}{d_val}\n**{S.BJ_UI['player']}:** {p_str}{p_val}" if doubled_total: desc += "\n" + S.BJ["doubled_label"].format(total=_coin(doubled_total)) embed = discord.Embed(title=title, description=desc, color=color) if result_field: embed.add_field(name=result_field[0], value=result_field[1], inline=False) return embed class BlackjackView(discord.ui.View): def __init__( self, user_id: int, bet: int, player_hand: list, dealer_hand: list, deck: list, ): super().__init__(timeout=120) self.user_id = user_id self.bet = bet # original per-hand bet self.hands: list[list] = [player_hand] self.bets: list[int] = [bet] self.hand_idx: int = 0 self.dealer_hand = dealer_hand self.deck = deck self._doubled_hands: set[int] = set() self._split_aces: bool = False self.message: discord.Message | None = None self._refresh_buttons() @property def _cur_hand(self) -> list: return self.hands[self.hand_idx] def _can_split(self) -> bool: return ( len(self.hands) == 1 and len(self._cur_hand) == 2 and self._cur_hand[0][0] == self._cur_hand[1][0] ) def _refresh_buttons(self) -> None: self.clear_items() is_split = len(self.hands) > 1 can_double = ( not is_split and 0 not in self._doubled_hands and len(self._cur_hand) == 2 ) hit_btn = discord.ui.Button(label=S.BJ["btn_hit"], style=discord.ButtonStyle.primary) hit_btn.callback = self._hit stand_btn = discord.ui.Button(label=S.BJ["btn_stand"], style=discord.ButtonStyle.secondary) stand_btn.callback = self._stand double_btn = discord.ui.Button( label=S.BJ["btn_double"].format(bet=self.bet), style=discord.ButtonStyle.success, disabled=not can_double, ) double_btn.callback = self._double self.add_item(hit_btn) self.add_item(stand_btn) self.add_item(double_btn) if self._can_split(): split_btn = discord.ui.Button( label=S.BJ["btn_split"].format(bet=self.bet), style=discord.ButtonStyle.danger, ) split_btn.callback = self._split_hand self.add_item(split_btn) def _cur_embed(self, game_over: bool = False, hand_results: list | None = None) -> discord.Embed: if not self.dealer_hand: d_str, d_val = "-", "" elif not game_over: d_str = _bj_hand_str(self.dealer_hand, hide_second=True) d_val = f" `{_bj_value([self.dealer_hand[0]])}`" else: d_str = _bj_hand_str(self.dealer_hand) d_val = f" `{_bj_value(self.dealer_hand)}`" desc = f"**{S.BJ_UI['dealer']}:** {d_str}{d_val}" if len(self.hands) == 1: hand = self.hands[0] pv = _bj_value(hand) doubled_str = f" 💰 *{_coin(self.bets[0])}*" if 0 in self._doubled_hands else "" desc += f"\n**{S.BJ_UI['player']}:** {_bj_hand_str(hand)} `{pv}`{doubled_str}" else: for i, hand in enumerate(self.hands): pv = _bj_value(hand) if hand_results and i < len(hand_results): icon = {"win": "✅", "push": "🤝", "lose": "❌"}[hand_results[i]] label = f"{icon} " + S.BJ_UI["hand_n"].format(n=i + 1) elif game_over or i < self.hand_idx: label = S.BJ_UI["hand_n"].format(n=i + 1) elif i == self.hand_idx: label = S.BJ_UI["hand_active"].format(n=i + 1) else: label = S.BJ_UI["hand_pending"].format(n=i + 1) bust_str = S.BJ_UI["bust"] if pv > 21 else "" desc += f"\n**{label}:** {_bj_hand_str(hand)} `{pv}`{bust_str}" return discord.Embed(title=S.TITLE["blackjack"], description=desc, color=0x5865F2) async def _resolve_all(self, interaction: discord.Interaction) -> None: _active_games.discard(self.user_id) self.clear_items() self.stop() dv = _bj_value(self.dealer_hand) total_payout = 0 hand_results: list[str] = [] for hand, bet in zip(self.hands, self.bets): pv = _bj_value(hand) if pv > 21: hand_results.append("lose") elif dv > 21 or pv > dv: hand_results.append("win") total_payout += bet * 2 elif pv == dv: hand_results.append("push") total_payout += bet else: hand_results.append("lose") total_invested = sum(self.bets) res = await economy.do_blackjack_payout(self.user_id, total_payout, total_invested) net = total_payout - total_invested result_str = ( f"+{_coin(total_payout)}" if net > 0 else (S.BJ["push_result"] if net == 0 else f"-{_coin(total_invested)}") ) if len(self.hands) == 1: r = hand_results[0] doubled = 0 in self._doubled_hands if r == "win": title_key, color = ("blackjack_dwin" if doubled else "blackjack_win"), 0x57F287 elif r == "push": title_key, color = "blackjack_push", 0x99AAB5 else: pv = _bj_value(self.hands[0]) if pv > 21: title_key = "blackjack_dbust" if doubled else "blackjack_bust" else: title_key = "blackjack_lose" color = 0xED4245 else: if net > 0: title_key, color = "blackjack_win", 0x57F287 elif net == 0: title_key, color = "blackjack_push", 0x99AAB5 else: title_key, color = "blackjack_lose", 0xED4245 embed = self._cur_embed(game_over=True, hand_results=hand_results) embed.title = S.TITLE[title_key] embed.color = color embed.add_field( name=S.BJ["result_field"], value=result_str + S.BJ_UI["balance_line"].format(balance=_coin(res["balance"])), inline=False, ) await self.message.edit(embed=embed, view=self) if total_payout > total_invested: asyncio.create_task(_award_exp(interaction, economy.gamble_exp(total_invested))) async def _do_dealer_reveal(self, interaction: discord.Interaction) -> None: await self.message.edit(embed=self._cur_embed(game_over=True), view=None) await asyncio.sleep(_BJ_DEAL_DELAY) while _bj_value(self.dealer_hand) < 17: self.dealer_hand.append(self.deck.pop()) await self.message.edit(embed=self._cur_embed(game_over=True), view=None) await asyncio.sleep(_BJ_DEAL_DELAY) await self._resolve_all(interaction) async def _advance_or_finish(self, interaction: discord.Interaction) -> None: self.hand_idx += 1 if self.hand_idx < len(self.hands): self._refresh_buttons() await self.message.edit(embed=self._cur_embed(), view=self) else: self.hand_idx = len(self.hands) - 1 await self._do_dealer_reveal(interaction) async def _hit(self, interaction: discord.Interaction) -> None: if interaction.user.id != self.user_id: await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) return await interaction.response.defer() self._cur_hand.append(self.deck.pop()) val = _bj_value(self._cur_hand) if val > 21: await self.message.edit(embed=self._cur_embed(), view=None) await asyncio.sleep(_BJ_DEAL_DELAY) if len(self.hands) > 1: await self._advance_or_finish(interaction) else: await self._resolve_all(interaction) elif val == 21: await self.message.edit(embed=self._cur_embed(), view=None) await asyncio.sleep(_BJ_DEAL_DELAY * 0.5) if len(self.hands) > 1: await self._advance_or_finish(interaction) else: await self._do_dealer_reveal(interaction) else: self._refresh_buttons() await self.message.edit(embed=self._cur_embed(), view=self) async def _stand(self, interaction: discord.Interaction) -> None: if interaction.user.id != self.user_id: await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) return await interaction.response.defer() if len(self.hands) > 1: await self._advance_or_finish(interaction) else: await self._do_dealer_reveal(interaction) async def _double(self, interaction: discord.Interaction) -> None: if interaction.user.id != self.user_id: await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) return res = await economy.do_blackjack_bet(self.user_id, self.bet) if not res["ok"]: await interaction.response.send_message( S.ERR["broke"].format(bal=_coin(res.get("balance", 0))), ephemeral=True ) return await interaction.response.defer() self._doubled_hands.add(0) self.bets[0] *= 2 self._cur_hand.append(self.deck.pop()) await self.message.edit(embed=self._cur_embed(), view=None) await asyncio.sleep(_BJ_DEAL_DELAY) await self._do_dealer_reveal(interaction) async def _split_hand(self, interaction: discord.Interaction) -> None: if interaction.user.id != self.user_id: await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True) return res = await economy.do_blackjack_bet(self.user_id, self.bet) if not res["ok"]: await interaction.response.send_message( S.ERR["broke"].format(bal=_coin(res.get("balance", 0))), ephemeral=True ) return await interaction.response.defer() card1, card2 = self._cur_hand[0], self._cur_hand[1] self._split_aces = card1[0] == "A" self.hands = [[card1, self.deck.pop()], [card2, self.deck.pop()]] self.bets = [self.bet, self.bet] self.hand_idx = 0 await self.message.edit(embed=self._cur_embed(), view=None) await asyncio.sleep(_BJ_DEAL_DELAY) if self._split_aces: await self._do_dealer_reveal(interaction) else: self._refresh_buttons() await self.message.edit(embed=self._cur_embed(), view=self) async def on_timeout(self) -> None: _active_games.discard(self.user_id) try: await economy.do_blackjack_payout(self.user_id, 0, sum(self.bets)) except Exception: pass self.clear_items() if self.message: try: await self.message.edit(view=self) except discord.HTTPException: pass @tree.command(name="blackjack", description=S.CMD["blackjack"]) @app_commands.describe(panus=S.OPT["blackjack_panus"]) async def cmd_blackjack(interaction: discord.Interaction, panus: str): _data = await economy.get_user(interaction.user.id) bet, _err = _parse_amount(panus, _data["balance"]) if _err: await interaction.response.send_message(_err, ephemeral=True) return if bet <= 0: await interaction.response.send_message(S.ERR["positive_bet"], ephemeral=True) return if interaction.user.id in _active_games: await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True) return res = await economy.do_blackjack_bet(interaction.user.id, bet) if not res["ok"]: if res["reason"] == "banned": await interaction.response.send_message(S.MSG_BANNED, ephemeral=True) elif res["reason"] == "jailed": await interaction.response.send_message( S.CD_MSG["jailed"].format(ts=_cd_ts(res["remaining"])), ephemeral=True ) else: await interaction.response.send_message( S.ERR["broke"].format(bal=_coin(_data["balance"])), ephemeral=True ) return _active_games.add(interaction.user.id) deck = _bj_deck() player_hand: list = [] dealer_hand: list = [] # ── Animated deal: player, dealer, player, dealer ───────────────────── await interaction.response.send_message( embed=discord.Embed(title=S.TITLE["blackjack"], description=S.BJ["dealing"], color=0x5865F2) ) msg = await interaction.original_response() for target in ["player", "dealer", "player", "dealer"]: if target == "player": player_hand.append(deck.pop()) else: dealer_hand.append(deck.pop()) await asyncio.sleep(_BJ_DEAL_DELAY) await msg.edit(embed=_bj_embed(player_hand, dealer_hand, S.TITLE["blackjack"], 0x5865F2, hide_dealer=True)) await asyncio.sleep(_BJ_DEAL_DELAY * 0.5) # ── Immediate blackjack check ───────────────────────────────────────── if _bj_is_blackjack(player_hand): # Flip dealer card before resolving so player can see both hands await msg.edit(embed=_bj_embed(player_hand, dealer_hand, S.TITLE["blackjack"], 0x5865F2, hide_dealer=False)) await asyncio.sleep(_BJ_DEAL_DELAY) if _bj_is_blackjack(dealer_hand): push_res = await economy.do_blackjack_payout(interaction.user.id, bet, bet) embed = _bj_embed( player_hand, dealer_hand, S.TITLE["blackjack_push"], 0x99AAB5, hide_dealer=False, result_field=(S.BJ["result_field"], S.BJ["push_result"] + S.BJ_UI["balance_line"].format(balance=_coin(push_res["balance"]))), ) else: payout = bet + int(bet * 1.5) bj_res = await economy.do_blackjack_payout(interaction.user.id, payout, bet) embed = _bj_embed( player_hand, dealer_hand, S.TITLE["blackjack_bj"], 0xF4C430, hide_dealer=False, result_field=(S.BJ["result_field"], f"+{_coin(payout)}" + S.BJ_UI["balance_line"].format(balance=_coin(bj_res["balance"]))), ) asyncio.create_task(_award_exp(interaction, economy.gamble_exp(bet))) _active_games.discard(interaction.user.id) await msg.edit(embed=embed) return # ── Normal game ─────────────────────────────────────────────────────── view = BlackjackView(interaction.user.id, bet, player_hand, dealer_hand, deck) view.message = msg await msg.edit( embed=_bj_embed(player_hand, dealer_hand, S.TITLE["blackjack"], 0x5865F2, hide_dealer=True), view=view, ) # --------------------------------------------------------------------------- # /request - crowdfunding # --------------------------------------------------------------------------- class FundModal(discord.ui.Modal): summa = discord.ui.TextInput( label=S.REQUEST_UI["modal_label"], min_length=1, max_length=10, ) def __init__(self, view: "RequestView"): super().__init__(title=S.REQUEST_UI["modal_title"]) self._view = view self.summa.placeholder = f"1 - {view.remaining}" async def on_submit(self, interaction: discord.Interaction): amount, _err = _parse_amount(self.summa.value, 0) if _err or amount is None: await interaction.response.send_message(S.ERR["invalid_amount"], ephemeral=True) return if amount <= 0 or amount > self._view.remaining: await interaction.response.send_message( S.ERR["fund_range"].format(max=self._view.remaining), ephemeral=True ) return res = await economy.do_give(interaction.user.id, self._view.requester.id, amount) if not res["ok"]: data = await economy.get_user(interaction.user.id) await interaction.response.send_message( S.ERR["broke"].format(bal=_coin(data["balance"])), ephemeral=True ) return self._view.remaining -= amount funded_line = S.REQUEST_UI["funded_line"].format(name=interaction.user.display_name, amount=_coin(amount)) if self._view.remaining <= 0: self._view.fund_btn.disabled = True self._view.fund_btn.label = S.REQUEST_UI["btn_funded"] self._view.fund_btn.style = discord.ButtonStyle.secondary self._view.stop() funded_line += S.REQUEST_UI["funded_full"] else: self._view.fund_btn.label = S.REQUEST_UI["btn_fund_remaining"].format(remaining=self._view.remaining) funded_line += S.REQUEST_UI["funded_partial"].format(remaining=_coin(self._view.remaining)) await interaction.response.send_message(funded_line) if self._view.message: await self._view.message.edit(view=self._view) class RequestView(discord.ui.View): def __init__(self, requester: discord.Member, amount: int, target: discord.Member | None): super().__init__(timeout=300) self.requester = requester self.remaining = amount self.target = target self.message: discord.Message | None = None self.fund_btn = discord.ui.Button(label=S.REQUEST_UI["btn_fund"], style=discord.ButtonStyle.success) self.fund_btn.callback = self._fund self.add_item(self.fund_btn) async def _fund(self, interaction: discord.Interaction): if interaction.user.id == self.requester.id: await interaction.response.send_message(S.ERR["request_self_fund"], ephemeral=True) return if self.target and interaction.user.id != self.target.id: await interaction.response.send_message( S.ERR["request_targeted"].format(name=self.target.display_name), ephemeral=True ) return await interaction.response.send_modal(FundModal(self)) async def on_timeout(self): for item in self.children: item.disabled = True _MAX_REQUEST = 1_000_000 @tree.command(name="request", description=S.CMD["request"]) @app_commands.describe( summa=S.OPT["request_summa"], põhjus=S.OPT["request_põhjus"], sihtmärk=S.OPT["request_sihtmärk"], ) async def cmd_request( interaction: discord.Interaction, summa: str, põhjus: str, sihtmärk: discord.Member | None = None, ): summa_int, _err = _parse_amount(summa, 0) if _err or summa_int is None: await interaction.response.send_message(_err or S.ERR["invalid_amount"], ephemeral=True) return if summa_int <= 0: await interaction.response.send_message(S.ERR["positive_amount"], ephemeral=True) return if summa_int > _MAX_REQUEST: await interaction.response.send_message( S.ERR["fund_range"].format(max=_coin(_MAX_REQUEST)), ephemeral=True ) return summa = summa_int if sihtmärk and sihtmärk.id == interaction.user.id: await interaction.response.send_message(S.ERR["request_self"], ephemeral=True) return if sihtmärk and sihtmärk.bot: await interaction.response.send_message(S.ERR["request_bot"], ephemeral=True) return audience = S.REQUEST_UI["audience_targeted"].format(name=sihtmärk.display_name) if sihtmärk else S.REQUEST_UI["audience_all"] embed = discord.Embed( title=S.TITLE["request"], description=S.REQUEST_UI["desc"].format(requester=interaction.user.display_name, amount=_coin(summa), reason=põhjus, audience=audience), color=0xF4C430, ) embed.set_footer(text=S.REQUEST_UI["footer"]) view = RequestView(interaction.user, summa, sihtmärk) await interaction.response.send_message(embed=embed, view=view) view.message = await interaction.original_response() # --------------------------------------------------------------------------- # /reminders # --------------------------------------------------------------------------- class RemindersSelect(discord.ui.Select): def __init__(self, user_id: int, current: list[str]): self.user_id = user_id options = [ discord.SelectOption( label=label, description=desc, value=cmd, default=cmd in current, ) for cmd, label, desc in S.REMINDER_OPTS ] super().__init__( placeholder=S.REMINDERS_UI["select_placeholder"], options=options, min_values=0, max_values=len(S.REMINDER_OPTS), ) async def callback(self, interaction: discord.Interaction): if interaction.user.id != self.user_id: await interaction.response.send_message(S.ERR["not_your_menu"], ephemeral=True) return await economy.do_set_reminders(self.user_id, self.values) enabled = set(self.values) for cmd in [opt[0] for opt in S.REMINDER_OPTS]: if cmd not in enabled: task = _reminder_tasks.pop((self.user_id, cmd), None) if task and not task.done(): task.cancel() if self.values: names = " ".join(f"`/{v}`" for v in self.values) msg = S.REMINDERS_UI["saved_on"].format(names=names) else: msg = S.REMINDERS_UI["saved_off"] await interaction.response.send_message(msg, ephemeral=True) class RemindersView(discord.ui.View): def __init__(self, user_id: int, current: list[str]): super().__init__(timeout=60) self.add_item(RemindersSelect(user_id, current)) @tree.command(name="reminders", description=S.CMD["reminders"]) async def cmd_reminders(interaction: discord.Interaction): user_data = await economy.get_user(interaction.user.id) current = user_data.get("reminders", []) if current: status = " ".join(f"`/{c}`" for c in current) desc = S.REMINDERS_UI["desc_active"].format(status=status) else: desc = S.REMINDERS_UI["desc_none"] embed = discord.Embed( title=S.TITLE["reminders"], description=desc, color=0x5865F2, ) embed.set_footer(text=S.REMINDERS_UI["footer"]) 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) # --------------------------------------------------------------------------- # Error handling for slash commands # --------------------------------------------------------------------------- @tree.error async def on_app_command_error(interaction: discord.Interaction, error: app_commands.AppCommandError): if isinstance(error, app_commands.MissingPermissions): msg = S.ERR["missing_perms"] else: log.exception("Unhandled slash command error: %s", error) msg = S.ERR["generic_error"].format(error=error) try: if interaction.response.is_done(): await interaction.followup.send(msg, ephemeral=True) else: await interaction.response.send_message(msg, ephemeral=True) except Exception: pass # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _log_sync_result(member: discord.Member, result: SyncResult): if result.nickname_changed: log.info(" → Nickname set for %s", member) if result.roles_added: log.info(" → Roles added for %s: %s", member, result.roles_added) if result.birthday_soon: log.info(" → Birthday coming up for %s", member) for err in result.errors: log.warning(" → %s", err) # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- def _asyncio_exception_handler(loop: asyncio.AbstractEventLoop, context: dict) -> None: exc = context.get("exception") msg = context.get("message", "unknown asyncio error") if exc: log.error("Unhandled asyncio exception: %s", msg, exc_info=exc) else: log.error("Unhandled asyncio error: %s", msg) if __name__ == "__main__": if not config.DISCORD_TOKEN: raise SystemExit("DISCORD_TOKEN pole seadistatud. Kopeeri .env.example failiks .env ja täida see.") async def _main() -> None: loop = asyncio.get_event_loop() loop.set_exception_handler(_asyncio_exception_handler) await bot.start(config.DISCORD_TOKEN, reconnect=True) asyncio.run(_main())