3797 lines
156 KiB
Python
3797 lines
156 KiB
Python
"""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 random
|
||
import re
|
||
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 sheets
|
||
from dev_member_commands import register_dev_member_commands
|
||
from dev_member_runtime import handle_member_join, run_birthday_daily
|
||
from economy_admin_commands import register_economy_admin_commands
|
||
from economy_prestige_commands import register_prestige_commands
|
||
from ops_channel_commands import register_ops_channel_commands
|
||
from ops_admin_commands import register_ops_admin_commands
|
||
from member_sync import SyncResult
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Logging
|
||
# ---------------------------------------------------------------------------
|
||
_LOG_DIR = Path("logs") / config.BOT_PROFILE
|
||
_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||
|
||
_fmt = logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
||
_txn_fmt = logging.Formatter("%(asctime)s | %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
|
||
|
||
# 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)
|
||
IS_DEV_PROFILE = config.BOT_PROFILE == "dev"
|
||
TALLINN_TZ = ZoneInfo("Europe/Tallinn")
|
||
_start_time = datetime.datetime.now()
|
||
_process = psutil.Process()
|
||
_DATA_DIR = Path("data") / config.BOT_PROFILE
|
||
_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||
_active_games: set[int] = set() # users with an in-progress interactive game
|
||
_active_heist: "HeistLobbyView | None" = None # server-wide singleton
|
||
# heist global CD is persisted on the house record in PocketBase (see economy.get/set_heist_global_cd)
|
||
_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
|
||
_gamble_cooldowns: dict[int, float] = {} # user_id -> monotonic timestamp of last gamble
|
||
_GAMBLE_CD = 30 # seconds (default gambling cooldown)
|
||
_GAMBLE_CD_360 = 25 # seconds (with monitor_360 item)
|
||
_BDAY_LOG = _DATA_DIR / "birthday_sent.json"
|
||
_RESTART_FILE = _DATA_DIR / "restart_channel.json"
|
||
_BOT_CONFIG = _DATA_DIR / "bot_config.json"
|
||
_PAUSED = False # maintenance mode: blocks non-admin commands when True
|
||
_DEV_ONLY_COMMANDS: tuple[str, ...] = ("birthdays", "check", "member")
|
||
|
||
|
||
def _apply_profile_command_filters() -> None:
|
||
"""Remove commands that should not exist for the active bot profile."""
|
||
if IS_DEV_PROFILE:
|
||
return
|
||
for name in _DEV_ONLY_COMMANDS:
|
||
removed = tree.remove_command(name)
|
||
if removed:
|
||
log.info("Profile '%s': removed dev-only command /%s", config.BOT_PROFILE, name)
|
||
|
||
|
||
def _load_bot_config() -> dict:
|
||
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)
|
||
|
||
|
||
def _get_paused() -> bool:
|
||
return _PAUSED
|
||
|
||
|
||
def _set_paused(value: bool) -> None:
|
||
global _PAUSED
|
||
_PAUSED = value
|
||
|
||
|
||
def _member_cache_size() -> int:
|
||
return len(sheets.get_cache())
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# EXP / Level role helpers
|
||
# ---------------------------------------------------------------------------
|
||
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")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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."""
|
||
if not IS_DEV_PROFILE:
|
||
return
|
||
await run_birthday_daily(
|
||
bot,
|
||
log,
|
||
has_announced_today=_has_announced_today,
|
||
mark_announced_today=_mark_announced_today,
|
||
)
|
||
|
||
|
||
@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)
|
||
|
||
_apply_profile_command_filters()
|
||
|
||
# Pull sheet data into cache
|
||
if IS_DEV_PROFILE:
|
||
try:
|
||
data = sheets.refresh()
|
||
log.info("Loaded %d member rows from Google Sheets", len(data))
|
||
except Exception as e:
|
||
log.error("Failed to load sheet on startup: %s", e)
|
||
|
||
# Sync slash commands to the guild only; wipe any leftover global registrations
|
||
tree.copy_global_to(guild=GUILD_OBJ)
|
||
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 IS_DEV_PROFILE and 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."""
|
||
if not IS_DEV_PROFILE:
|
||
return
|
||
await handle_member_join(
|
||
member,
|
||
bot,
|
||
log,
|
||
has_announced_today=_has_announced_today,
|
||
mark_announced_today=_mark_announced_today,
|
||
log_sync_result=_log_sync_result,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Slash commands
|
||
# ---------------------------------------------------------------------------
|
||
if IS_DEV_PROFILE:
|
||
register_dev_member_commands(
|
||
tree,
|
||
bot,
|
||
log,
|
||
has_announced_today=_has_announced_today,
|
||
mark_announced_today=_mark_announced_today,
|
||
)
|
||
|
||
register_ops_admin_commands(
|
||
tree,
|
||
bot,
|
||
log,
|
||
process=_process,
|
||
start_time=_start_time,
|
||
log_dir=_LOG_DIR,
|
||
guild_obj=GUILD_OBJ,
|
||
restart_file=_RESTART_FILE,
|
||
get_member_cache_size=_member_cache_size,
|
||
get_paused=_get_paused,
|
||
set_paused=_set_paused,
|
||
count_economy_users=pb_client.count_records,
|
||
)
|
||
|
||
register_ops_channel_commands(
|
||
tree,
|
||
bot,
|
||
log,
|
||
get_allowed_channels=_get_allowed_channels,
|
||
set_allowed_channels=_set_allowed_channels,
|
||
)
|
||
|
||
|
||
@tree.command(name="ping", description=S.CMD["ping"])
|
||
async def cmd_ping(interaction: discord.Interaction):
|
||
await interaction.response.send_message(S.MSG_PONG)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# /help
|
||
# ---------------------------------------------------------------------------
|
||
_HELP_PAGE_SIZE = 10
|
||
_DEV_ONLY_HELP_TOKENS: tuple[str, ...] = tuple(f"/{name}" for name in _DEV_ONLY_COMMANDS)
|
||
|
||
|
||
def _visible_help_fields(category_key: str) -> list[tuple[str, str]]:
|
||
fields: list[tuple[str, str]] = list(S.HELP_CATEGORIES[category_key]["fields"])
|
||
if IS_DEV_PROFILE:
|
||
return fields
|
||
|
||
visible: list[tuple[str, str]] = []
|
||
for name, value in fields:
|
||
blob = f"{name}\n{value}".lower()
|
||
if any(tok in blob for tok in _DEV_ONLY_HELP_TOKENS):
|
||
continue
|
||
visible.append((name, value))
|
||
return visible
|
||
|
||
|
||
def _help_embed(category_key: str, page: int = 0) -> discord.Embed:
|
||
cat = S.HELP_CATEGORIES[category_key]
|
||
fields = _visible_help_fields(category_key)
|
||
total_pages = max(1, math.ceil(len(fields) / _HELP_PAGE_SIZE))
|
||
page = max(0, min(page, total_pages - 1))
|
||
page_fields = fields[page * _HELP_PAGE_SIZE : (page + 1) * _HELP_PAGE_SIZE]
|
||
title = cat["label"]
|
||
if total_pages > 1:
|
||
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 = _visible_help_fields(self.category)
|
||
total_pages = max(1, math.ceil(len(fields) / _HELP_PAGE_SIZE))
|
||
self.page = max(0, min(self.page, total_pages - 1))
|
||
select_row = 0
|
||
if total_pages > 1:
|
||
select_row = 1
|
||
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 = max(1, math.ceil(len(_visible_help_fields(self.category)) / _HELP_PAGE_SIZE))
|
||
self.page = min(total - 1, self.page + 1)
|
||
self._rebuild()
|
||
await interaction.response.edit_message(embed=_help_embed(self.category, self.page), view=self)
|
||
|
||
|
||
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
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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"<t:{expiry}:R>"
|
||
|
||
|
||
register_economy_admin_commands(
|
||
tree,
|
||
bot,
|
||
log,
|
||
cd_ts=_cd_ts,
|
||
apply_level_role=_apply_level_role,
|
||
)
|
||
|
||
|
||
def _gamble_cd(uid: int, has_360: bool = False) -> datetime.timedelta | None:
|
||
"""Check and set gambling cooldown. Returns remaining time if on CD, else None."""
|
||
cd = float(_GAMBLE_CD_360 if has_360 else _GAMBLE_CD)
|
||
now = time.monotonic()
|
||
remaining = cd - (now - _gamble_cooldowns.get(uid, 0.0))
|
||
if remaining > 0:
|
||
return datetime.timedelta(seconds=remaining)
|
||
_gamble_cooldowns[uid] = now
|
||
return None
|
||
|
||
|
||
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
|
||
|
||
|
||
register_prestige_commands(
|
||
tree,
|
||
check_cmd_rate=_check_cmd_rate,
|
||
ensure_level_role=_ensure_level_role,
|
||
)
|
||
|
||
|
||
def _parse_amount(value: str, balance: int) -> tuple[int | None, str | None]:
|
||
"""Parse an amount string; 'all' resolves to the user's full balance.
|
||
Accepts plain integers and valid thousand-separated numbers (1,000 / 1.000 / 1 000).
|
||
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",
|
||
"fish": "last_fish",
|
||
}
|
||
|
||
|
||
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)
|
||
elif cmd == "fish" and "ussipurk" in items:
|
||
cooldown = datetime.timedelta(seconds=90)
|
||
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)
|
||
elif cmd == "fish" and "ussipurk" in items:
|
||
delay = datetime.timedelta(seconds=90)
|
||
else:
|
||
delay = economy.COOLDOWNS.get(cmd, datetime.timedelta(hours=1))
|
||
_schedule_reminder(user_id, cmd, delay)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# /profile - tabbed profile view
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _profile_main_embed(target: discord.User | discord.Member, data: dict) -> discord.Embed:
|
||
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.PROFILE_UI["main_title"].format(name=target.display_name),
|
||
color=0xF4C430,
|
||
)
|
||
embed.add_field(name=S.PROFILE_UI["f_balance"], value=_coin(data.get("balance", 0)), inline=True)
|
||
embed.add_field(name=S.PROFILE_UI["f_level"], value=S.PROFILE_UI["level_val"].format(level=level, role=role_name), inline=True)
|
||
streak = data.get("daily_streak", 0)
|
||
if streak:
|
||
embed.add_field(name=S.PROFILE_UI["f_streak"], value=S.BALANCE_UI["streak_val"].format(streak=streak), inline=True)
|
||
p_level = data.get("prestige_level", 0)
|
||
if p_level > 0:
|
||
p_pp = data.get("prestige_points", 0)
|
||
embed.add_field(name=S.PROFILE_UI["f_prestige"], value=S.PROFILE_UI["prestige_val"].format(level=p_level, pp=p_pp), inline=True)
|
||
jail_remaining = economy._is_jailed(data)
|
||
if jail_remaining:
|
||
embed.add_field(name=S.PROFILE_UI["f_jail"], value=_cd_ts(jail_remaining), inline=True)
|
||
embed.add_field(
|
||
name=S.PROFILE_UI["f_progress"].format(next=next_level),
|
||
value=S.PROFILE_UI["progress_bar"].format(bar=bar, done=progress, needed=needed),
|
||
inline=False,
|
||
)
|
||
if level < 10:
|
||
embed.set_footer(text=S.PROFILE_UI["footer_t1"])
|
||
elif level < 20:
|
||
embed.set_footer(text=S.PROFILE_UI["footer_t2"])
|
||
else:
|
||
embed.set_footer(text=S.PROFILE_UI["footer_t3"])
|
||
return embed
|
||
|
||
|
||
def _profile_items_embed(target: discord.User | discord.Member, data: dict) -> discord.Embed:
|
||
embed = discord.Embed(
|
||
title=S.PROFILE_UI["items_title"].format(name=target.display_name),
|
||
color=0xF4C430,
|
||
)
|
||
uses_map = data.get("item_uses", {})
|
||
item_lines = []
|
||
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)
|
||
embed.description = "\n".join(item_lines) if item_lines else S.PROFILE_UI["items_empty"]
|
||
return embed
|
||
|
||
|
||
def _profile_stats_embed(target: discord.User | discord.Member, data: dict) -> discord.Embed:
|
||
def _s(key: str) -> int:
|
||
return data.get(key, 0)
|
||
embed = discord.Embed(
|
||
title=S.PROFILE_UI["stats_title"].format(name=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,
|
||
)
|
||
return embed
|
||
|
||
|
||
def _profile_fish_embed(target: discord.User | discord.Member, fish_res: dict) -> discord.Embed:
|
||
embed = discord.Embed(
|
||
title=S.PROFILE_UI["fish_title"].format(name=target.display_name),
|
||
color=0x5865F2,
|
||
)
|
||
book: dict = fish_res["book"]
|
||
if not book:
|
||
embed.description = S.FISH_UI["book_empty"]
|
||
return embed
|
||
inv_counts: dict = fish_res.get("inv_counts", {})
|
||
caught_count = fish_res["unique_caught"]
|
||
total = fish_res["total_species"]
|
||
lines = [S.FISH_UI["book_caught"].format(caught=caught_count, total=total)]
|
||
for fish_id, fish_data in economy.FISH_CATALOGUE.items():
|
||
rarity = fish_data["rarity"]
|
||
emoji = S.FISH_RARITY_EMOJI[rarity]
|
||
rarity_name = S.FISH_RARITY_NAMES[rarity]
|
||
count = book.get(fish_id, 0)
|
||
if count > 0:
|
||
n_inv = inv_counts.get(fish_id, 0)
|
||
inv_str = S.FISH_UI["book_inv"].format(n=n_inv) if n_inv > 0 else ""
|
||
lines.append(S.FISH_UI["book_yes"].format(emoji=emoji, name=S.FISH_NAMES[fish_id], rarity=rarity_name, count=count, inv=inv_str))
|
||
else:
|
||
lines.append(S.FISH_UI["book_no"].format(rarity=rarity_name))
|
||
embed.description = "\n".join(lines)
|
||
embed.set_footer(text=S.FISH_UI["book_footer"].format(page=1, total_pages=1, caught=caught_count, total=total))
|
||
return embed
|
||
|
||
|
||
class ProfileView(discord.ui.View):
|
||
def __init__(self, target: discord.User | discord.Member, invoker_id: int, tab: str = "main"):
|
||
super().__init__(timeout=120)
|
||
self.target = target
|
||
self.invoker_id = invoker_id
|
||
self.tab = tab
|
||
self._rebuild()
|
||
|
||
def _rebuild(self):
|
||
self.clear_items()
|
||
tabs = [
|
||
("main", S.PROFILE_UI["btn_profile"]),
|
||
("items", S.PROFILE_UI["btn_items"]),
|
||
("stats", S.PROFILE_UI["btn_stats"]),
|
||
("fish", S.PROFILE_UI["btn_fish"]),
|
||
]
|
||
for tab_id, label in tabs:
|
||
btn = discord.ui.Button(
|
||
label=label,
|
||
style=discord.ButtonStyle.primary if tab_id == self.tab else discord.ButtonStyle.secondary,
|
||
disabled=(tab_id == self.tab),
|
||
)
|
||
btn.callback = self._make_cb(tab_id)
|
||
self.add_item(btn)
|
||
|
||
def _make_cb(self, tab_id: str):
|
||
async def _cb(interaction: discord.Interaction):
|
||
if interaction.user.id != self.invoker_id:
|
||
await interaction.response.send_message(S.ERR["not_your_menu"], ephemeral=True)
|
||
return
|
||
self.tab = tab_id
|
||
self._rebuild()
|
||
await interaction.response.defer()
|
||
data = await economy.get_user(self.target.id)
|
||
if tab_id == "fish":
|
||
fish_res = await economy.do_fishbook(self.target.id)
|
||
embed = _profile_fish_embed(self.target, fish_res)
|
||
inv: list = data.get("fish_inventory") or []
|
||
if inv and self.target.id == self.invoker_id:
|
||
total_value = sum(e.get("value", 0) for e in inv)
|
||
sell_btn = discord.ui.Button(
|
||
label=f"{S.FISH_UI['btn_sell']} ({len(inv)} kala · {total_value:,} {economy.COIN})",
|
||
style=discord.ButtonStyle.success,
|
||
row=1,
|
||
)
|
||
sell_btn.callback = self._sell_fish_cb()
|
||
self.add_item(sell_btn)
|
||
elif tab_id == "items":
|
||
embed = _profile_items_embed(self.target, data)
|
||
elif tab_id == "stats":
|
||
embed = _profile_stats_embed(self.target, data)
|
||
else:
|
||
embed = _profile_main_embed(self.target, data)
|
||
await interaction.edit_original_response(embed=embed, view=self)
|
||
return _cb
|
||
|
||
def _sell_fish_cb(self):
|
||
async def _cb(interaction: discord.Interaction):
|
||
if interaction.user.id != self.invoker_id:
|
||
await interaction.response.send_message(S.ERR["not_your_menu"], ephemeral=True)
|
||
return
|
||
await interaction.response.defer()
|
||
res = await economy.do_fish_sell(self.invoker_id)
|
||
self.tab = "fish"
|
||
self._rebuild()
|
||
fish_res = await economy.do_fishbook(self.target.id)
|
||
embed = _profile_fish_embed(self.target, fish_res)
|
||
sold_line = S.FISH_UI["inv_sold_all"].format(count=res.get("count", 0), coins=_coin(res.get("coins", 0)), balance=_coin(res.get("balance", 0)))
|
||
embed.description = f"{sold_line}\n\n{embed.description or ''}"
|
||
await interaction.edit_original_response(embed=embed, view=self)
|
||
return _cb
|
||
|
||
|
||
@tree.command(name="profile", description=S.CMD["profile"])
|
||
@app_commands.describe(kasutaja=S.OPT["profile_kasutaja"])
|
||
async def cmd_profile(interaction: discord.Interaction, kasutaja: discord.Member | None = None):
|
||
target = kasutaja or interaction.user
|
||
data = await economy.get_user(target.id)
|
||
embed = _profile_main_embed(target, data)
|
||
invoker_id = interaction.user.id
|
||
await interaction.response.send_message(embed=embed, view=ProfileView(target, invoker_id))
|
||
if not kasutaja and interaction.guild:
|
||
member = interaction.guild.get_member(target.id)
|
||
if member:
|
||
asyncio.create_task(_ensure_level_role(member, economy.get_level(data.get("exp", 0))))
|
||
|
||
|
||
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"⏳ <t:{ts}:R>"
|
||
|
||
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"]
|
||
fish_cd = datetime.timedelta(seconds=90) if "ussipurk" in items else economy.COOLDOWNS["fish"]
|
||
|
||
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"])),
|
||
S.COOLDOWNS_UI["fish_line"].format(status=_status("last_fish", fish_cd), note=S.COOLDOWNS_UI["note_ussipurk"] if "ussipurk" in items else ""),
|
||
]
|
||
|
||
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,
|
||
)
|
||
p_level = data.get("prestige_level", 0)
|
||
p_pp = data.get("prestige_points", 0)
|
||
s_exp = data.get("season_total_exp", 0)
|
||
if p_level > 0 or s_exp > 0:
|
||
embed.add_field(
|
||
name="\u200b",
|
||
value=S.PRESTIGE_UI["rank_line"].format(level=p_level, pp=p_pp) + "\n" + S.PRESTIGE_UI["rank_season"].format(exp=s_exp),
|
||
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"]))
|
||
try:
|
||
await sihtmärk.send(S.ROB_UI["victim_dm"].format(
|
||
robber=interaction.user.display_name,
|
||
stolen=_coin(res["stolen"]),
|
||
))
|
||
except discord.Forbidden:
|
||
pass
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# /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, organizer_has_jellyfin: bool = False):
|
||
super().__init__(timeout=_HEIST_JOIN_WINDOW)
|
||
self.organizer = organizer
|
||
self.participants: list[discord.Member] = [organizer]
|
||
self.message: discord.Message | None = None
|
||
self.resolved = False
|
||
self.jellyfin_holders: int = 1 if organizer_has_jellyfin else 0
|
||
|
||
def _chance(self) -> float:
|
||
n = len(self.participants)
|
||
base = min(_HEIST_BASE_CHANCE + _HEIST_CHANCE_STEP * (n - 1), _HEIST_MAX_CHANCE)
|
||
jelly_bonus = 0.05 if self.jellyfin_holders > 0 else 0.0
|
||
return min(base + jelly_bonus, _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)
|
||
joiner_data = await economy.get_user(interaction.user.id)
|
||
if "jellyfin" in joiner_data.get("items", []):
|
||
self.jellyfin_holders += 1
|
||
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
|
||
|
||
organizer_data = await economy.get_user(interaction.user.id)
|
||
view = HeistLobbyView(interaction.user, "jellyfin" in organizer_data.get("items", []))
|
||
_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._rolling = False
|
||
self._add_roll_btn()
|
||
|
||
def _add_roll_btn(self):
|
||
self.clear_items()
|
||
btn = discord.ui.Button(
|
||
label=S.JAILBREAK_UI["btn_roll"].format(try_=self.tries + 1, max=self.MAX_TRIES),
|
||
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._rolling:
|
||
await interaction.response.defer()
|
||
return
|
||
self._rolling = True
|
||
|
||
# Show rolling animation immediately
|
||
self.clear_items()
|
||
rolling_embed = discord.Embed(
|
||
title=S.TITLE["jailbreak"],
|
||
description=S.JAILBREAK_UI["rolling_desc"],
|
||
color=0xF4C430,
|
||
)
|
||
await interaction.response.edit_message(embed=rolling_embed, view=self)
|
||
|
||
# Roll both dice, then reveal after delay
|
||
d1 = random.randint(1, 6)
|
||
d2 = random.randint(1, 6)
|
||
e1, e2 = _DICE_EMOJI[d1 - 1], _DICE_EMOJI[d2 - 1]
|
||
double = d1 == d2
|
||
self.tries += 1
|
||
tries_left = self.MAX_TRIES - self.tries
|
||
|
||
await asyncio.sleep(1.5)
|
||
self._rolling = False
|
||
|
||
if double:
|
||
await economy.do_jail_free(self.user_id)
|
||
self.stop()
|
||
embed = discord.Embed(
|
||
title=S.TITLE["jailbreak_free"],
|
||
description=S.JAILBREAK_UI["free_desc"].format(d1=e1, d2=e2),
|
||
color=0x57F287,
|
||
)
|
||
await interaction.edit_original_response(embed=embed, view=self)
|
||
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.edit_original_response(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.edit_original_response(embed=embed, view=None)
|
||
else:
|
||
self._add_roll_btn()
|
||
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.edit_original_response(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
|
||
has_360 = "monitor_360" in _data.get("items", [])
|
||
if rem := _gamble_cd(interaction.user.id, has_360):
|
||
await interaction.response.send_message(
|
||
S.ERR["gamble_cooldown"].format(ts=_cd_ts(rem)), 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, data: dict, guild: discord.Guild | None, bot_user: discord.ClientUser | None):
|
||
super().__init__(timeout=120)
|
||
self.data = data
|
||
self.guild = guild
|
||
self.bot_user = bot_user
|
||
self.page = 0
|
||
self.mode = "coins"
|
||
self.max_page = 0
|
||
self._update_buttons()
|
||
|
||
def _current_list(self) -> list:
|
||
return self.data.get(self.mode, [])
|
||
|
||
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
|
||
for m, btn in [
|
||
("coins", self.coins_btn),
|
||
("exp", self.exp_btn),
|
||
("season", self.season_btn),
|
||
("prestige",self.prestige_btn),
|
||
("wagered", self.wagered_btn),
|
||
("fish", self.fish_btn),
|
||
]:
|
||
btn.style = discord.ButtonStyle.primary if m == self.mode else discord.ButtonStyle.secondary
|
||
|
||
def _name(self, uid: str, highlight_uid: int | None = None) -> str:
|
||
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} ‹**"
|
||
return name
|
||
|
||
def _make_embed(self, highlight_uid: int | None = None) -> discord.Embed:
|
||
title_map = {
|
||
"coins": f"{economy.COIN} {S.TITLE['leaderboard_coins']}",
|
||
"exp": S.TITLE["leaderboard_exp"],
|
||
"season": S.TITLE["leaderboard_season"],
|
||
"prestige": S.TITLE["leaderboard_prestige"],
|
||
"wagered": S.TITLE["leaderboard_wagered"],
|
||
"fish": S.TITLE["leaderboard_fish"],
|
||
}
|
||
color_map = {"coins": 0xF4C430, "wagered": 0xED4245, "fish": 0x57F287}
|
||
embed = discord.Embed(title=title_map.get(self.mode, "Edetabel"), color=color_map.get(self.mode, 0x5865F2))
|
||
lines = []
|
||
|
||
if self.mode == "coins" and self.page == 0 and self.data.get("house_entry"):
|
||
_, bal = self.data["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}.**"
|
||
name = self._name(uid, highlight_uid)
|
||
if self.mode == "coins":
|
||
lines.append(f"{prefix} {name} - {_coin(entry[1])}")
|
||
elif self.mode == "exp":
|
||
lines.append(S.LEADERBOARD_UI["exp_entry"].format(prefix=prefix, name=name, exp=entry[1], level=entry[2]))
|
||
elif self.mode == "season":
|
||
lines.append(S.LEADERBOARD_UI["season_entry"].format(prefix=prefix, name=name, exp=entry[1], prestige=entry[2]))
|
||
elif self.mode == "prestige":
|
||
lines.append(S.LEADERBOARD_UI["prestige_entry"].format(prefix=prefix, name=name, prestige=entry[1], pp=entry[2]))
|
||
elif self.mode == "wagered":
|
||
lines.append(S.LEADERBOARD_UI["wagered_entry"].format(prefix=prefix, name=name, wagered=_coin(entry[1])))
|
||
elif self.mode == "fish":
|
||
lines.append(S.LEADERBOARD_UI["fish_entry"].format(prefix=prefix, name=name, caught=entry[1]))
|
||
|
||
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, row=0)
|
||
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, row=0)
|
||
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_find_me"], style=discord.ButtonStyle.secondary, row=0)
|
||
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)
|
||
|
||
@discord.ui.button(label=S.LEADERBOARD_UI["btn_coins"], style=discord.ButtonStyle.primary, row=1)
|
||
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, row=1)
|
||
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_season"], style=discord.ButtonStyle.secondary, row=1)
|
||
async def season_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||
self.mode = "season"; 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_prestige"], style=discord.ButtonStyle.secondary, row=1)
|
||
async def prestige_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||
self.mode = "prestige"; 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_wagered"], style=discord.ButtonStyle.secondary, row=1)
|
||
async def wagered_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||
self.mode = "wagered"; 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_fish"], style=discord.ButtonStyle.secondary, row=2)
|
||
async def fish_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||
self.mode = "fish"; self.page = 0; self._update_buttons()
|
||
await interaction.response.edit_message(embed=self._make_embed(), view=self)
|
||
|
||
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()
|
||
coins_raw, exp_raw, season_raw, prestige_raw, wagered_raw, fish_raw = await asyncio.gather(
|
||
economy.get_leaderboard(top_n=None),
|
||
economy.get_leaderboard_exp(top_n=None),
|
||
economy.get_leaderboard_season_exp(top_n=None),
|
||
economy.get_leaderboard_prestige(top_n=None),
|
||
economy.get_leaderboard_wagered(top_n=None),
|
||
economy.get_leaderboard_fish(top_n=None),
|
||
)
|
||
|
||
house_entry = None
|
||
regular = []
|
||
bot_id = bot.user.id if bot.user else None
|
||
for uid, bal in coins_raw:
|
||
if bot_id and int(uid) == bot_id:
|
||
house_entry = (uid, bal)
|
||
else:
|
||
regular.append((uid, bal))
|
||
|
||
def _no_bot(entries: list) -> list:
|
||
return [e for e in entries if not (bot_id and int(e[0]) == bot_id)]
|
||
|
||
data = {
|
||
"coins": regular,
|
||
"exp": _no_bot(exp_raw),
|
||
"season": _no_bot(season_raw),
|
||
"prestige": _no_bot(prestige_raw),
|
||
"wagered": _no_bot(wagered_raw),
|
||
"fish": _no_bot(fish_raw),
|
||
"house_entry": house_entry,
|
||
}
|
||
view = LeaderboardView(data, interaction.guild, bot.user)
|
||
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
|
||
if panus_int > 0:
|
||
has_360 = "monitor_360" in _data.get("items", [])
|
||
if rem := _gamble_cd(interaction.user.id, has_360):
|
||
await interaction.response.send_message(
|
||
S.ERR["gamble_cooldown"].format(ts=_cd_ts(rem)), 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 = "<a:TipiSLOTS:1483444233863037101>"
|
||
_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
|
||
has_360 = "monitor_360" in _data.get("items", [])
|
||
if rem := _gamble_cd(interaction.user.id, has_360):
|
||
await interaction.response.send_message(
|
||
S.ERR["gamble_cooldown"].format(ts=_cd_ts(rem)), 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
|
||
has_360 = "monitor_360" in _data.get("items", [])
|
||
if rem := _gamble_cd(interaction.user.id, has_360):
|
||
await interaction.response.send_message(
|
||
S.ERR["gamble_cooldown"].format(ts=_cd_ts(rem)), 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)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# /fish - fishing minigame
|
||
# ---------------------------------------------------------------------------
|
||
class FishCatchView(discord.ui.View):
|
||
"""Shown after a successful pull - lets user sell or keep the fish."""
|
||
|
||
def __init__(self, user_id: int, res: dict, fish_id: str, weight: int):
|
||
super().__init__(timeout=60)
|
||
self.user_id = user_id
|
||
self._res = res
|
||
self._fish_id = fish_id
|
||
self._weight = weight
|
||
self._done = False
|
||
|
||
def _catch_embed(self, color: int = 0x57F287) -> discord.Embed:
|
||
rarity = economy.FISH_CATALOGUE[self._fish_id]["rarity"]
|
||
emoji = S.FISH_RARITY_EMOJI[rarity]
|
||
fish_name = S.FISH_NAMES[self._fish_id]
|
||
desc = S.FISH_UI["catch_desc"].format(
|
||
name=fish_name, weight=self._weight,
|
||
exp=self._res["exp"], value=_coin(self._res["value"]),
|
||
)
|
||
if self._res.get("is_new"):
|
||
desc += S.FISH_UI["new_fish"]
|
||
return discord.Embed(title=f"{emoji} {fish_name}!", description=desc, color=color)
|
||
|
||
@discord.ui.button(label="", style=discord.ButtonStyle.success)
|
||
async def sell_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||
if interaction.user.id != self.user_id:
|
||
await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True)
|
||
return
|
||
if self._done:
|
||
await interaction.response.defer()
|
||
return
|
||
self._done = True
|
||
self.stop()
|
||
for child in self.children:
|
||
child.disabled = True
|
||
sell_res = await economy.do_fish_sell(self.user_id, [-1])
|
||
rarity = economy.FISH_CATALOGUE[self._fish_id]["rarity"]
|
||
emoji = S.FISH_RARITY_EMOJI[rarity]
|
||
fish_name = S.FISH_NAMES[self._fish_id]
|
||
desc = S.FISH_UI["catch_sold"].format(
|
||
name=fish_name, weight=self._weight,
|
||
coins=_coin(sell_res["coins"]), exp=self._res["exp"],
|
||
balance=_coin(sell_res["balance"]),
|
||
)
|
||
if self._res.get("is_new"):
|
||
desc += S.FISH_UI["new_fish"]
|
||
embed = discord.Embed(title=f"{emoji} {fish_name}!", description=desc, color=0x57F287)
|
||
await interaction.response.edit_message(embed=embed, view=self)
|
||
|
||
@discord.ui.button(label="", style=discord.ButtonStyle.secondary)
|
||
async def keep_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||
if interaction.user.id != self.user_id:
|
||
await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True)
|
||
return
|
||
if self._done:
|
||
await interaction.response.defer()
|
||
return
|
||
self._done = True
|
||
self.stop()
|
||
for child in self.children:
|
||
child.disabled = True
|
||
rarity = economy.FISH_CATALOGUE[self._fish_id]["rarity"]
|
||
emoji = S.FISH_RARITY_EMOJI[rarity]
|
||
fish_name = S.FISH_NAMES[self._fish_id]
|
||
desc = S.FISH_UI["catch_kept"].format(name=fish_name, weight=self._weight, exp=self._res["exp"])
|
||
if self._res.get("is_new"):
|
||
desc += S.FISH_UI["new_fish"]
|
||
embed = discord.Embed(title=f"{emoji} {fish_name}!", description=desc, color=0x5865F2)
|
||
await interaction.response.edit_message(embed=embed, view=self)
|
||
|
||
async def on_timeout(self):
|
||
for child in self.children:
|
||
child.disabled = True
|
||
|
||
|
||
class FishingView(discord.ui.View):
|
||
BITE_WINDOW = 2.0
|
||
|
||
def __init__(self, user_id: int, fish_id: str, weight: int):
|
||
super().__init__(timeout=40)
|
||
self.user_id = user_id
|
||
self._fish_id = fish_id
|
||
self._weight = weight
|
||
self._clicked = False
|
||
self._bite_active = False
|
||
self._msg: discord.Message | None = None
|
||
|
||
self.pull_btn = discord.ui.Button(
|
||
label=S.FISH_UI["btn_wait"],
|
||
style=discord.ButtonStyle.secondary,
|
||
disabled=True,
|
||
)
|
||
self.pull_btn.callback = self._pull
|
||
self.add_item(self.pull_btn)
|
||
|
||
async def start(self, msg: discord.Message) -> None:
|
||
self._msg = msg
|
||
wait = random.uniform(5, 15)
|
||
await asyncio.sleep(wait)
|
||
if self._clicked or self.is_finished():
|
||
return
|
||
self._bite_active = True
|
||
self.pull_btn.disabled = False
|
||
self.pull_btn.label = S.FISH_UI["btn_bite"]
|
||
self.pull_btn.style = discord.ButtonStyle.success
|
||
try:
|
||
await msg.edit(
|
||
embed=discord.Embed(title=S.TITLE["fish_bite"], description=S.FISH_UI["bite_desc"], color=0xED4245),
|
||
view=self,
|
||
)
|
||
except Exception:
|
||
pass
|
||
await asyncio.sleep(self.BITE_WINDOW)
|
||
if not self._clicked:
|
||
self.stop()
|
||
_active_games.discard(self.user_id)
|
||
self.pull_btn.disabled = True
|
||
try:
|
||
await msg.edit(
|
||
embed=discord.Embed(title=S.TITLE["fish_escape"], description=S.FISH_UI["escape_desc"], color=0x99AAB5),
|
||
view=self,
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
async def _pull(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
|
||
if not self._bite_active:
|
||
await interaction.response.send_message(S.FISH_UI["too_early"], ephemeral=True)
|
||
return
|
||
self._clicked = True
|
||
self.stop()
|
||
_active_games.discard(self.user_id)
|
||
self.pull_btn.disabled = True
|
||
await interaction.response.defer()
|
||
|
||
if self._fish_id == "junk":
|
||
junk_text = random.choice(S.FISH_JUNK_LINES)
|
||
user_data = await economy.get_user(interaction.user.id)
|
||
embed = discord.Embed(
|
||
title=S.TITLE["fish_junk"],
|
||
description=S.FISH_UI["junk_desc"].format(text=junk_text, balance=_coin(user_data.get("balance", 0))),
|
||
color=0x99AAB5,
|
||
)
|
||
await self._msg.edit(embed=embed, view=self)
|
||
return
|
||
|
||
res = await economy.do_fish_resolve(self.user_id, self._fish_id, self._weight)
|
||
if not res["ok"]:
|
||
await self._msg.edit(embed=discord.Embed(title=S.ERR["generic_error"], color=0xED4245), view=self)
|
||
return
|
||
|
||
catch_view = FishCatchView(self.user_id, res, self._fish_id, self._weight)
|
||
catch_view.sell_btn.label = S.FISH_UI["btn_sell"]
|
||
catch_view.keep_btn.label = S.FISH_UI["btn_keep"]
|
||
await self._msg.edit(embed=catch_view._catch_embed(), view=catch_view)
|
||
if res.get("exp", 0) > 0:
|
||
asyncio.create_task(_award_exp(interaction, res["exp"]))
|
||
|
||
async def on_timeout(self):
|
||
for child in self.children:
|
||
child.disabled = True
|
||
_active_games.discard(self.user_id)
|
||
|
||
|
||
@tree.command(name="fish", description=S.CMD["fish"])
|
||
@app_commands.guild_only()
|
||
async def cmd_fish(interaction: discord.Interaction):
|
||
if await _check_cmd_rate(interaction):
|
||
return
|
||
if interaction.user.id in _active_games:
|
||
await interaction.response.send_message(S.ERR["game_in_progress"], ephemeral=True)
|
||
return
|
||
|
||
res = await economy.do_fish_start(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["fish"].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
|
||
|
||
user_data = await economy.get_user(interaction.user.id)
|
||
rarity_bump = "kalavork" in user_data.get("items", [])
|
||
has_echolood = "echolood" in user_data.get("items", [])
|
||
fish_id, weight = economy.roll_fish(rarity_bump=rarity_bump)
|
||
|
||
_active_games.add(interaction.user.id)
|
||
view = FishingView(interaction.user.id, fish_id, weight)
|
||
if has_echolood:
|
||
view.BITE_WINDOW = 3.0
|
||
embed = discord.Embed(title=S.TITLE["fish_cast"], description=S.FISH_UI["cast_desc"], color=0x5865F2)
|
||
await interaction.response.send_message(embed=embed, view=view)
|
||
msg = await interaction.original_response()
|
||
asyncio.create_task(view.start(msg))
|
||
asyncio.create_task(_maybe_remind(interaction.user.id, "fish"))
|
||
|
||
|
||
@tree.command(name="fishbook", description=S.CMD["fishbook"])
|
||
@app_commands.describe(kasutaja=S.OPT["fishbook_kasutaja"])
|
||
async def cmd_fishbook(interaction: discord.Interaction, kasutaja: discord.Member | None = None):
|
||
target = kasutaja or interaction.user
|
||
res = await economy.do_fishbook(target.id)
|
||
book: dict = res["book"]
|
||
total = res["total_species"]
|
||
caught_count = res["unique_caught"]
|
||
|
||
if not book:
|
||
embed = discord.Embed(
|
||
title=S.TITLE["fishbook"],
|
||
description=S.FISH_UI["book_empty"],
|
||
color=0x5865F2,
|
||
)
|
||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||
return
|
||
|
||
inv_counts: dict = res.get("inv_counts", {})
|
||
all_fish = list(economy.FISH_CATALOGUE.items())
|
||
lines = [S.FISH_UI["book_caught"].format(caught=caught_count, total=total)]
|
||
for fish_id, fish_data in all_fish:
|
||
rarity = fish_data["rarity"]
|
||
emoji = S.FISH_RARITY_EMOJI[rarity]
|
||
rarity_name = S.FISH_RARITY_NAMES[rarity]
|
||
count = book.get(fish_id, 0)
|
||
if count > 0:
|
||
n_inv = inv_counts.get(fish_id, 0)
|
||
inv_str = S.FISH_UI["book_inv"].format(n=n_inv) if n_inv > 0 else ""
|
||
lines.append(S.FISH_UI["book_yes"].format(emoji=emoji, name=S.FISH_NAMES[fish_id], rarity=rarity_name, count=count, inv=inv_str))
|
||
else:
|
||
lines.append(S.FISH_UI["book_no"].format(rarity=rarity_name))
|
||
|
||
embed = discord.Embed(
|
||
title=S.TITLE["fishbook"].replace("Kalakogu", f"{target.display_name} kalakogu"),
|
||
description="\n".join(lines),
|
||
color=0x5865F2,
|
||
)
|
||
embed.set_footer(text=S.FISH_UI["book_footer"].format(page=1, total_pages=1, caught=caught_count, total=total))
|
||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# /fishsell
|
||
# ---------------------------------------------------------------------------
|
||
@tree.command(name="fishsell", description=S.CMD["fishsell"])
|
||
@app_commands.guild_only()
|
||
async def cmd_fishsell(interaction: discord.Interaction):
|
||
await interaction.response.defer(ephemeral=True)
|
||
user_data = await economy.get_user(interaction.user.id)
|
||
inv: list = user_data.get("fish_inventory") or []
|
||
if not inv:
|
||
embed = discord.Embed(
|
||
title=S.TITLE["fishbook"],
|
||
description=S.FISH_UI["inv_empty"],
|
||
color=0x5865F2,
|
||
)
|
||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||
return
|
||
|
||
total_value = sum(e["value"] for e in inv)
|
||
lines = [S.FISH_UI["inv_header"].format(count=len(inv), total_value=_coin(total_value))]
|
||
for entry in inv:
|
||
fid = entry.get("fish_id", "")
|
||
rarity = economy.FISH_CATALOGUE.get(fid, {}).get("rarity", "common")
|
||
emoji = S.FISH_RARITY_EMOJI.get(rarity, "🐟")
|
||
name = S.FISH_NAMES.get(fid, fid)
|
||
lines.append(S.FISH_UI["inv_entry"].format(emoji=emoji, name=name, weight=entry["weight"], value=_coin(entry["value"])))
|
||
|
||
embed = discord.Embed(title=S.TITLE["fishbook"], description="\n".join(lines), color=0x5865F2)
|
||
|
||
sell_all_btn = discord.ui.Button(label=S.FISH_UI["btn_sell"] + f" ({_coin(total_value)})", style=discord.ButtonStyle.success)
|
||
|
||
async def _sell_all(btn_interaction: discord.Interaction):
|
||
if btn_interaction.user.id != interaction.user.id:
|
||
await btn_interaction.response.send_message(S.ERR["not_your_menu"], ephemeral=True)
|
||
return
|
||
sell_view.stop()
|
||
for child in sell_view.children:
|
||
child.disabled = True
|
||
res = await economy.do_fish_sell(interaction.user.id)
|
||
if not res["ok"]:
|
||
await btn_interaction.response.edit_message(
|
||
embed=discord.Embed(description=S.FISH_UI["inv_none"], color=0x99AAB5), view=sell_view
|
||
)
|
||
return
|
||
sold_embed = discord.Embed(
|
||
title=S.TITLE["fishbook"],
|
||
description=S.FISH_UI["inv_sold_all"].format(
|
||
count=res["count"], coins=_coin(res["coins"]), balance=_coin(res["balance"])
|
||
),
|
||
color=0x57F287,
|
||
)
|
||
await btn_interaction.response.edit_message(embed=sold_embed, view=sell_view)
|
||
|
||
sell_all_btn.callback = _sell_all
|
||
sell_view = discord.ui.View(timeout=60)
|
||
sell_view.add_item(sell_all_btn)
|
||
await interaction.followup.send(embed=embed, view=sell_view, 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:
|
||
profile_key = "DISCORD_TOKEN_ECONOMY" if config.BOT_PROFILE == "economy" else "DISCORD_TOKEN_DEV"
|
||
raise SystemExit(
|
||
f"{profile_key} pole seadistatud profiilile '{config.BOT_PROFILE}'. "
|
||
"Kopeeri .env.example failiks .env ja täida see."
|
||
)
|
||
|
||
async def _main() -> None:
|
||
loop = asyncio.get_event_loop()
|
||
loop.set_exception_handler(_asyncio_exception_handler)
|
||
await bot.start(config.DISCORD_TOKEN, reconnect=True)
|
||
|
||
asyncio.run(_main())
|