3800 lines
155 KiB
Python
3800 lines
155 KiB
Python
"""TipiLAN Bot - Discord member management powered by Google Sheets."""
|
||
|
||
import asyncio
|
||
import collections
|
||
import datetime
|
||
import json
|
||
import logging
|
||
import logging.handlers
|
||
import math
|
||
import 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())
|