Files
tipibot/bot.py
2026-03-20 17:35:35 +02:00

3800 lines
155 KiB
Python
Raw Permalink Blame History

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