4 Commits

Author SHA1 Message Date
Rene Arumetsa
07360d3f11 Remove logs from git 2026-04-20 22:28:24 +03:00
8f28832432 Merge pull request 'Feature: Start with rewrite-v2' (#1) from rewrite-v2 into master
Reviewed-on: renkar/tipibot#1
2026-04-20 19:09:45 +00:00
AlacrisDevs
4d1981420d Fucking shit pask bad 2026-04-20 12:09:39 +03:00
AlacrisDevs
14927b610d Discomboluating 2026-04-04 21:06:43 +03:00
27 changed files with 5176 additions and 21247 deletions

View File

@@ -1,5 +1,12 @@
# Discord bot token (from https://discord.com/developers/applications)
DISCORD_TOKEN=your-bot-token-here
# Bot runtime profile: dev (economy + member tools) or economy (economy-only)
BOT_PROFILE=dev
# Profile-specific Discord bot tokens (from https://discord.com/developers/applications)
DISCORD_TOKEN_DEV=your-dev-bot-token-here
DISCORD_TOKEN_ECONOMY=your-economy-bot-token-here
# Legacy fallback token (optional, backward compatibility)
DISCORD_TOKEN=
# Google Sheets spreadsheet ID (the long string in the sheet URL)
SHEET_ID=your-google-sheet-id-here
@@ -7,11 +14,21 @@ SHEET_ID=your-google-sheet-id-here
# Path to Google service account credentials JSON
GOOGLE_CREDS_PATH=credentials.json
# Guild (server) ID - right-click your server with dev mode on
GUILD_ID=your-guild-id-here
# Profile-specific guild (server) IDs - right-click your server with dev mode on
GUILD_ID_DEV=your-dev-guild-id-here
GUILD_ID_ECONOMY=your-economy-guild-id-here
# Channel ID where birthday announcements are posted (bot sends @here on the birthday at 09:00 Tallinn time)
BIRTHDAY_CHANNEL_ID=your-channel-id-here
# Legacy fallback guild ID (optional, backward compatibility)
GUILD_ID=
# Channel ID where birthday announcements are posted (dev profile)
BIRTHDAY_CHANNEL_ID_DEV=your-dev-birthday-channel-id-here
# Optional birthday channel for economy profile (normally unset for economy-only bot)
BIRTHDAY_CHANNEL_ID_ECONOMY=
# Legacy fallback birthday channel ID (optional, backward compatibility)
BIRTHDAY_CHANNEL_ID=
# How many days before a birthday the on-join check counts as "coming up"
BIRTHDAY_WINDOW_DAYS=7
@@ -20,3 +37,10 @@ BIRTHDAY_WINDOW_DAYS=7
PB_URL=http://127.0.0.1:8090
PB_ADMIN_EMAIL=admin@example.com
PB_ADMIN_PASSWORD=your-pb-admin-password
# Profile-specific PocketBase collections
PB_ECONOMY_COLLECTION_DEV=economy_users_dev
PB_ECONOMY_COLLECTION_ECONOMY=economy_users_prod
# Legacy fallback collection name (optional, backward compatibility)
PB_ECONOMY_COLLECTION=

View File

@@ -70,7 +70,7 @@ The economy system stores all player data in [PocketBase](https://pocketbase.io/
1. Download `pocketbase.exe` (Windows) from https://pocketbase.io/docs/ and place it in the project root.
2. Start PocketBase: `.\pocketbase.exe serve`
3. Open the admin UI at http://127.0.0.1:8090/_/ and create a superuser account.
4. Create a collection named `economy_users` - see `docs/POCKETBASE_SETUP.md` for the full schema.
4. Create two collections for profile separation: `economy_users_dev` and `economy_users_prod` - see `docs/POCKETBASE_SETUP.md` for schema notes.
5. Set `PB_URL`, `PB_ADMIN_EMAIL`, `PB_ADMIN_PASSWORD` in `.env`.
6. **One-time data migration** (only if you have an existing `data/economy.json`): `python scripts/migrate_to_pb.py`
@@ -88,15 +88,25 @@ cp .env.example .env
| Variable | Description |
|---|---|
| `DISCORD_TOKEN` | Bot token from Discord Developer Portal |
| `BOT_PROFILE` | Runtime profile: `dev` or `economy` |
| `DISCORD_TOKEN_DEV` | Dev bot token from Discord Developer Portal |
| `DISCORD_TOKEN_ECONOMY` | Economy bot token from Discord Developer Portal |
| `DISCORD_TOKEN` | Legacy fallback token (optional) |
| `SHEET_ID` | ID from the Google Sheet URL |
| `GOOGLE_CREDS_PATH` | Path to `credentials.json` (default: `credentials.json`) |
| `GUILD_ID` | Right-click server → Copy Server ID (Developer Mode on) |
| `BIRTHDAY_CHANNEL_ID` | Channel for birthday `@here` pings |
| `GUILD_ID_DEV` | Dev bot guild ID |
| `GUILD_ID_ECONOMY` | Economy bot guild ID |
| `GUILD_ID` | Legacy fallback guild ID (optional) |
| `BIRTHDAY_CHANNEL_ID_DEV` | Channel for birthday `@here` pings in dev profile |
| `BIRTHDAY_CHANNEL_ID_ECONOMY` | Optional birthday channel in economy profile |
| `BIRTHDAY_CHANNEL_ID` | Legacy fallback birthday channel ID (optional) |
| `BIRTHDAY_WINDOW_DAYS` | Days before birthday that on-join check flags it (default: 7) |
| `PB_URL` | PocketBase base URL (default: `http://127.0.0.1:8090`) |
| `PB_ADMIN_EMAIL` | PocketBase superuser e-mail |
| `PB_ADMIN_PASSWORD` | PocketBase superuser password |
| `PB_ECONOMY_COLLECTION_DEV` | PocketBase collection used by `BOT_PROFILE=dev` |
| `PB_ECONOMY_COLLECTION_ECONOMY` | PocketBase collection used by `BOT_PROFILE=economy` |
| `PB_ECONOMY_COLLECTION` | Legacy fallback collection (optional) |
### 6. Install & Run
@@ -110,7 +120,12 @@ pip install -r requirements.txt
# Terminal 1 - keep running
.\pocketbase.exe serve
# Terminal 2
# Terminal 2 (dev bot)
set BOT_PROFILE=dev
python bot.py
# Terminal 3 (economy bot)
set BOT_PROFILE=economy
python bot.py
```
@@ -146,6 +161,8 @@ Admins (bot lacks permission to modify them) are silently skipped and still mark
## Admin Commands
> These commands are hidden from users who don't have the required permission. Override per-role in **Server Settings → Integrations → Bot**.
>
> Profile note: `/check`, `/member`, and `/birthdays` are available only when `BOT_PROFILE=dev`.
| Command | Permission | What it does |
|---|---|---|

4249
bot.py

File diff suppressed because it is too large Load Diff

View File

@@ -3,14 +3,58 @@ from dotenv import load_dotenv
load_dotenv()
DISCORD_TOKEN = os.getenv("DISCORD_TOKEN")
BOT_PROFILE = os.getenv("BOT_PROFILE", "dev").strip().lower() or "dev"
if BOT_PROFILE not in {"dev", "economy"}:
raise SystemExit("BOT_PROFILE must be either 'dev' or 'economy'.")
def _env_int(name: str, default: int) -> int:
raw = os.getenv(name)
if raw is None or not raw.strip():
return default
return int(raw)
_LEGACY_DISCORD_TOKEN = os.getenv("DISCORD_TOKEN", "")
DISCORD_TOKEN_DEV = os.getenv("DISCORD_TOKEN_DEV", "")
DISCORD_TOKEN_ECONOMY = os.getenv("DISCORD_TOKEN_ECONOMY", "")
DISCORD_TOKEN = (
DISCORD_TOKEN_ECONOMY if BOT_PROFILE == "economy" else DISCORD_TOKEN_DEV
) or _LEGACY_DISCORD_TOKEN
SHEET_ID = os.getenv("SHEET_ID")
GOOGLE_CREDS_PATH = os.getenv("GOOGLE_CREDS_PATH", "credentials.json")
GUILD_ID = int(os.getenv("GUILD_ID", "0"))
BIRTHDAY_CHANNEL_ID = int(os.getenv("BIRTHDAY_CHANNEL_ID", "0"))
_LEGACY_GUILD_ID = _env_int("GUILD_ID", 0)
GUILD_ID_DEV = _env_int("GUILD_ID_DEV", _LEGACY_GUILD_ID)
GUILD_ID_ECONOMY = _env_int("GUILD_ID_ECONOMY", _LEGACY_GUILD_ID)
GUILD_ID = GUILD_ID_ECONOMY if BOT_PROFILE == "economy" else GUILD_ID_DEV
_LEGACY_BIRTHDAY_CHANNEL_ID = _env_int("BIRTHDAY_CHANNEL_ID", 0)
BIRTHDAY_CHANNEL_ID_DEV = _env_int("BIRTHDAY_CHANNEL_ID_DEV", _LEGACY_BIRTHDAY_CHANNEL_ID)
BIRTHDAY_CHANNEL_ID_ECONOMY = _env_int("BIRTHDAY_CHANNEL_ID_ECONOMY", 0)
BIRTHDAY_CHANNEL_ID = (
BIRTHDAY_CHANNEL_ID_ECONOMY
if BOT_PROFILE == "economy"
else BIRTHDAY_CHANNEL_ID_DEV
)
BIRTHDAY_WINDOW_DAYS = int(os.getenv("BIRTHDAY_WINDOW_DAYS", "7"))
BASE_ROLE_IDS: list[int] = [1478304631930228779, 1478302278862766190]
PB_URL = os.getenv("PB_URL", "http://127.0.0.1:8090")
PB_ADMIN_EMAIL = os.getenv("PB_ADMIN_EMAIL", "")
PB_ADMIN_PASSWORD = os.getenv("PB_ADMIN_PASSWORD", "")
_LEGACY_PB_COLLECTION = os.getenv("PB_ECONOMY_COLLECTION", "").strip()
PB_ECONOMY_COLLECTION_DEV = (
os.getenv("PB_ECONOMY_COLLECTION_DEV", "").strip()
or (_LEGACY_PB_COLLECTION if _LEGACY_PB_COLLECTION else "economy_users_dev")
)
PB_ECONOMY_COLLECTION_ECONOMY = (
os.getenv("PB_ECONOMY_COLLECTION_ECONOMY", "").strip()
or (_LEGACY_PB_COLLECTION if _LEGACY_PB_COLLECTION else "economy_users_prod")
)
PB_ECONOMY_COLLECTION = (
PB_ECONOMY_COLLECTION_ECONOMY if BOT_PROFILE == "economy" else PB_ECONOMY_COLLECTION_DEV
)

314
dev_member_commands.py Normal file
View File

@@ -0,0 +1,314 @@
from __future__ import annotations
import datetime
import logging
from typing import Callable
import discord
from discord import app_commands
import sheets
import strings as S
from member_sync import announce_birthday, sync_member
class BirthdayPages(discord.ui.View):
def __init__(self, pages: list[discord.Embed], start: int = 0):
super().__init__(timeout=120)
self.pages = pages
self.current = start
self._update_buttons()
def _update_buttons(self):
self.prev_button.disabled = self.current == 0
self.next_button.disabled = self.current >= len(self.pages) - 1
@discord.ui.button(label="", style=discord.ButtonStyle.secondary)
async def prev_button(self, interaction: discord.Interaction, button: discord.ui.Button):
self.current -= 1
self._update_buttons()
await interaction.response.edit_message(embed=self.pages[self.current], view=self)
@discord.ui.button(label="", style=discord.ButtonStyle.secondary)
async def next_button(self, interaction: discord.Interaction, button: discord.ui.Button):
self.current += 1
self._update_buttons()
await interaction.response.edit_message(embed=self.pages[self.current], view=self)
def _build_birthday_pages(
guild: discord.Guild | None = None,
) -> tuple[list[discord.Embed], int]:
"""Build 12 monthly embeds (one per calendar month).
Returns (pages, start_index) where start_index is the current month.
"""
rows = sheets.get_cache()
today = datetime.date.today()
by_month: dict[int, list[tuple[int, str, int | None]]] = {m: [] for m in range(1, 13)}
for row in rows:
name = str(row.get("Nimi", "")).strip()
bday_str = str(row.get("Sünnipäev", "")).strip()
if not name or not bday_str or bday_str.strip().lower() in ("-", "x", "n/a", "none", "ei"):
continue
bday = None
for fmt in ["%d/%m/%Y", "%Y-%m-%d", "%m-%d"]:
try:
bday = datetime.datetime.strptime(bday_str, fmt).date()
break
except ValueError:
continue
if bday is None:
continue
raw_uid = str(row.get("User ID", "")).strip()
try:
uid = int(raw_uid) if raw_uid and raw_uid not in ("0", "-") else None
except ValueError:
uid = None
by_month[bday.month].append((bday.day, name, uid))
pages: list[discord.Embed] = []
for month in range(1, 13):
entries = sorted(by_month[month], key=lambda x: x[0])
embed = discord.Embed(
title=f"🎂 {S.BIRTHDAY_MONTHS[month - 1]}",
color=0xF4A261,
)
if not entries:
embed.description = S.BIRTHDAY_UI["no_entries"]
else:
lines = []
for day, name, uid in entries:
try:
this_year = datetime.date(today.year, month, day)
except ValueError:
this_year = datetime.date(today.year, month, day - 1)
next_bday = this_year if this_year >= today else this_year.replace(year=today.year + 1)
days_until = (next_bday - today).days
if days_until == 0:
when = S.BIRTHDAY_UI["today"]
elif days_until == 1:
when = S.BIRTHDAY_UI["tomorrow"]
else:
when = S.BIRTHDAY_UI["in_days"].format(days=days_until)
display = name
if guild and uid:
member = guild.get_member(uid)
if member:
display = member.mention
lines.append(f"{display} - {day:02d}/{month:02d} · {when}")
embed.description = "\n".join(lines)
embed.set_footer(text=S.BIRTHDAY_UI["footer"].format(month=month, month_name=S.BIRTHDAY_MONTHS[month - 1]))
pages.append(embed)
return pages, today.month - 1
def _sheet_stats(rows: list[dict]) -> str:
"""Return a formatted string with sheet completeness statistics."""
total = len(rows)
missing_uid = []
missing_discord = []
missing_birthday = []
for row in rows:
name = str(row.get("Nimi", "")).strip() or S.CHECK_UI["no_name"]
uid = str(row.get("User ID", "")).strip()
discord_name = str(row.get("Discord", "")).strip()
bday = str(row.get("Sünnipäev", "")).strip()
if not uid or uid == "0":
missing_uid.append(name)
if not discord_name:
missing_discord.append(name)
if not bday:
missing_birthday.append(name)
lines = [S.CHECK_UI["sheet_stats_header"].format(total=total)]
lines.append("")
def stat_line(label: str, missing: list[str]) -> str:
count = len(missing)
if count == 0:
return S.CHECK_UI["stat_ok"].format(label=label)
names = ", ".join(missing[:5])
more = S.CHECK_UI["stat_more"].format(count=count - 5) if count > 5 else ""
return S.CHECK_UI["stat_warn"].format(label=label, count=count, names=names, more=more)
lines.append(stat_line(S.CHECK_UI["stat_uid"], missing_uid))
lines.append(stat_line(S.CHECK_UI["stat_discord"], missing_discord))
lines.append(stat_line(S.CHECK_UI["stat_bday"], missing_birthday))
return "\n".join(lines)
def register_dev_member_commands(
tree: app_commands.CommandTree,
bot: discord.Client,
log: logging.Logger,
has_announced_today: Callable[[int], bool],
mark_announced_today: Callable[[int], None],
) -> None:
@tree.command(name="birthdays", description=S.CMD["birthdays"])
@app_commands.guild_only()
async def cmd_birthdays(interaction: discord.Interaction):
await interaction.response.defer()
try:
sheets.refresh()
except Exception as e:
await interaction.followup.send(S.ERR["sheet_error"].format(error=e), ephemeral=True)
return
pages, start = _build_birthday_pages(guild=interaction.guild)
await interaction.followup.send(embed=pages[start], view=BirthdayPages(pages, start=start))
@tree.command(name="check", description=S.CMD["check"])
@app_commands.guild_only()
@app_commands.default_permissions(manage_roles=True)
async def cmd_check(interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
guild = interaction.guild
if guild is None:
await interaction.followup.send(S.ERR["guild_only"], ephemeral=True)
return
try:
data = sheets.refresh()
except Exception as e:
await interaction.followup.send(S.ERR["sheet_error"].format(error=e), ephemeral=True)
return
ids_filled = 0
for row in data:
uid = str(row.get("User ID", "")).strip()
if uid and uid not in ("0", "-"):
continue
discord_name = str(row.get("Discord", "")).strip()
if not discord_name:
continue
guild_member = discord.utils.find(
lambda m, n=discord_name: m.name.lower() == n.lower(),
guild.members,
)
if guild_member:
sheets.set_user_id(discord_name, guild_member.id)
ids_filled += 1
data = sheets.get_cache()
changed_count = 0
not_found = 0
already_ok = 0
errors_total = 0
birthday_pings = 0
details: list[str] = []
sync_updates: list[tuple[int, bool]] = []
for member in guild.members:
if member.bot:
continue
result = await sync_member(member, guild)
if result.not_found:
not_found += 1
continue
sync_updates.append((member.id, result.synced))
if result.errors:
errors_total += len(result.errors)
for err in result.errors:
details.append(S.CHECK_UI["detail_error"].format(error=err))
if result.changed:
changed_count += 1
parts = []
if result.nickname_changed:
parts.append(S.CHECK_UI["detail_nickname"])
if result.roles_added:
parts.append(S.CHECK_UI["detail_roles_added"].format(roles=", ".join(result.roles_added)))
details.append(S.CHECK_UI["detail_changed"].format(name=member.display_name, parts=", ".join(parts)))
else:
already_ok += 1
if result.birthday_soon and not has_announced_today(member.id):
birthday_pings += 1
await announce_birthday(member, bot)
mark_announced_today(member.id)
if sync_updates:
try:
sheets.batch_set_synced(sync_updates)
except Exception as e:
log.error("/check batch_set_synced failed: %s", e)
summary_lines = [
S.CHECK_UI["done"],
S.CHECK_UI["already_ok"].format(count=already_ok),
S.CHECK_UI["fixed"].format(count=changed_count),
S.CHECK_UI["not_found"].format(count=not_found),
S.CHECK_UI["bday_pings"].format(count=birthday_pings),
]
if errors_total:
summary_lines.append(S.CHECK_UI["errors"].format(count=errors_total))
summary = "\n".join(summary_lines)
if details:
detail_text = "\n".join(details[:20])
summary += f"\n\n{S.CHECK_UI['details_header']}\n{detail_text}"
if len(details) > 20:
summary += "\n" + S.CHECK_UI["details_more"].format(count=len(details) - 20)
stats = _sheet_stats(data)
id_note = S.CHECK_UI["ids_filled"].format(count=ids_filled) if ids_filled else ""
summary = id_note + "\n" + summary + "\n\n" + stats
await interaction.followup.send(summary.strip(), ephemeral=True)
log.info(
"/check - OK=%d, Fixed=%d, NotFound=%d, IDs=%d, Errors=%d",
already_ok,
changed_count,
not_found,
ids_filled,
errors_total,
)
@tree.command(name="member", description=S.CMD["member"])
@app_commands.guild_only()
@app_commands.default_permissions(manage_roles=True)
async def cmd_member(interaction: discord.Interaction, user: discord.Member):
row = sheets.find_member(user.id, user.name)
if row is None:
await interaction.response.send_message(
S.ERR["member_not_found"].format(name=user.display_name),
ephemeral=True,
)
return
embed = discord.Embed(title=S.MEMBER_UI["title"].format(name=user.display_name), color=user.color)
bday_str = str(row.get("Sünnipäev", "")).strip()
if bday_str and bday_str.lower() not in ("-", "x", "n/a", "none", "ei"):
for fmt in ["%d/%m/%Y", "%Y-%m-%d"]:
try:
bday = datetime.datetime.strptime(bday_str, fmt).date()
if 1920 <= bday.year <= datetime.date.today().year:
today = datetime.date.today()
age = today.year - bday.year - ((today.month, today.day) < (bday.month, bday.day))
embed.add_field(name=S.MEMBER_UI["age_field"], value=S.MEMBER_UI["age_val"].format(age=age), inline=True)
break
except ValueError:
continue
for sheet_key, label in S.MEMBER_FIELDS:
val = str(row.get(sheet_key, "")).strip()
if val:
embed.add_field(name=label, value=val, inline=True)
await interaction.response.send_message(embed=embed, ephemeral=True)

93
dev_member_runtime.py Normal file
View File

@@ -0,0 +1,93 @@
from __future__ import annotations
import logging
from collections.abc import Callable
import discord
import config
import sheets
from member_sync import announce_birthday, is_birthday_today, sync_member
async def run_birthday_daily(
bot: discord.Client,
log: logging.Logger,
has_announced_today: Callable[[int], bool],
mark_announced_today: Callable[[int], None],
) -> None:
"""Announce birthdays in the configured guild for users whose birthday is today."""
guild = bot.get_guild(config.GUILD_ID)
if guild is None:
log.warning("Birthday task: guild %s not found", config.GUILD_ID)
return
try:
data = sheets.refresh()
except Exception as e:
log.error("Birthday task: sheet refresh failed: %s", e)
data = sheets.get_cache()
announced = 0
for row in data:
bday_str = str(row.get("Sünnipäev", "")).strip()
if not is_birthday_today(bday_str):
continue
member = None
raw_id = str(row.get("User ID", "")).strip()
if raw_id:
try:
member = guild.get_member(int(raw_id))
except ValueError:
pass
if member is None:
discord_name = str(row.get("Discord", "")).strip()
if discord_name:
member = discord.utils.find(
lambda m, n=discord_name: m.name.lower() == n.lower(),
guild.members,
)
if member and not has_announced_today(member.id):
await announce_birthday(member, bot)
mark_announced_today(member.id)
announced += 1
log.info("Sünnipäeva ülesanne: teavitati %d liiget", announced)
async def handle_member_join(
member: discord.Member,
bot: discord.Client,
log: logging.Logger,
has_announced_today: Callable[[int], bool],
mark_announced_today: Callable[[int], None],
log_sync_result: Callable[[discord.Member, object], None],
) -> None:
"""Sync a newly joined member against sheet data and trigger birthday notice if needed."""
log.info("Member joined: %s (ID: %s)", member, member.id)
if not sheets.get_cache():
sheets.refresh()
result = await sync_member(member, member.guild)
if result.not_found:
try:
sheets.add_new_member_row(member.name, member.id)
log.info(
"%s not in sheet, added new row (Discord=%s, ID=%s)",
member,
member.name,
member.id,
)
except Exception as e:
log.error(" → Failed to add sheet row for %s: %s", member, e)
return
log_sync_result(member, result)
sheets.set_synced(member.id, result.synced)
if result.birthday_soon and not has_announced_today(member.id):
await announce_birthday(member, bot)
mark_announced_today(member.id)

View File

@@ -13,9 +13,14 @@ Create your admin account on first launch via the Admin UI.
---
## 2. Create the `economy_users` collection
## 2. Create economy collections for both bot profiles
In the Admin UI → **Collections****New collection** → name it exactly `economy_users`.
In the Admin UI → **Collections****New collection** and create:
- `economy_users_dev`
- `economy_users_prod`
Use the same schema for both collections.
Add the following fields:
@@ -51,6 +56,8 @@ Add to your `.env`:
PB_URL=http://127.0.0.1:8090
PB_ADMIN_EMAIL=your-admin@email.com
PB_ADMIN_PASSWORD=your-admin-password
PB_ECONOMY_COLLECTION_DEV=economy_users_dev
PB_ECONOMY_COLLECTION_ECONOMY=economy_users_prod
```
---

310
economy_admin_commands.py Normal file
View File

@@ -0,0 +1,310 @@
from __future__ import annotations
import datetime
import logging
from collections.abc import Awaitable, Callable
import discord
from discord import app_commands
import economy
import strings as S
async def _dm_user(bot: discord.Client, user_id: int, msg: str) -> None:
"""Best-effort DM to a user."""
try:
user = bot.get_user(user_id) or await bot.fetch_user(user_id)
await user.send(msg)
except Exception:
pass
def register_economy_admin_commands(
tree: app_commands.CommandTree,
bot: discord.Client,
log: logging.Logger,
cd_ts: Callable[[datetime.timedelta], str],
apply_level_role: Callable[[discord.Member, int, int], Awaitable[None]],
) -> None:
@tree.command(name="adminseason", description=S.CMD["adminseason"])
@app_commands.guild_only()
@app_commands.default_permissions(manage_guild=True)
@app_commands.describe(top_n=S.OPT["adminseason_top_n"])
async def cmd_adminseason(interaction: discord.Interaction, top_n: int = 10):
await interaction.response.defer(ephemeral=True)
top = await economy.do_season_reset(top_n)
guild = interaction.guild
if guild:
all_role_names = {name for _, name in economy.LEVEL_ROLES}
for role_name in all_role_names:
role = discord.utils.find(lambda r: r.name == role_name, guild.roles)
if not role:
continue
for member in list(role.members):
try:
await member.remove_roles(role, reason="Season reset")
except discord.Forbidden:
pass
medals = ["🥇", "🥈", "🥉"]
lines = []
for i, (uid, exp, lvl) in enumerate(top):
member = guild.get_member(int(uid)) if guild else None
name = member.display_name if member else S.LEADERBOARD_UI["unknown_user"].format(uid=uid)
prefix = medals[i] if i < 3 else f"**{i + 1}.**"
lines.append(S.SEASON["entry"].format(prefix=prefix, name=name, exp=exp, level=lvl))
embed = discord.Embed(
title=S.TITLE["adminseason"],
description=(S.SEASON["top_header"] + "\n" + "\n".join(lines)) if lines else S.SEASON["no_players"],
color=0xF4C430,
)
embed.set_footer(text=S.SEASON["footer"])
await interaction.followup.send(embed=embed, ephemeral=False)
await interaction.followup.send(S.SEASON["done"], ephemeral=True)
log.info("/adminseason triggered by %s (top_n=%d)", interaction.user, top_n)
@tree.command(name="admincoins", description=S.CMD["admincoins"])
@app_commands.guild_only()
@app_commands.describe(
kasutaja=S.OPT["admin_kasutaja"],
kogus=S.OPT["admincoins_kogus"],
põhjus=S.OPT["admin_põhjus"],
)
@app_commands.default_permissions(manage_guild=True)
async def cmd_admincoins(interaction: discord.Interaction, kasutaja: discord.Member, kogus: int, põhjus: str):
if kogus == 0:
await interaction.response.send_message(S.ERR["admincoins_zero"], ephemeral=True)
return
res = await economy.do_admin_coins(kasutaja.id, kogus, interaction.user.id, põhjus)
verb = f"+{kogus}" if kogus > 0 else str(kogus)
emoji = "💰" if kogus > 0 else "💸"
await interaction.response.send_message(
S.ADMIN["coins_done"].format(
emoji=emoji,
name=kasutaja.display_name,
verb=verb,
coin=economy.COIN,
balance=f"{res['balance']:,}",
reason=põhjus,
),
ephemeral=True,
)
await _dm_user(
bot,
kasutaja.id,
S.ADMIN["coins_dm"].format(
emoji=emoji,
verb=verb,
coin=economy.COIN,
reason=põhjus,
balance=f"{res['balance']:,}",
),
)
log.info("ADMINCOINS %s%s (%s) by %s", verb, kasutaja, põhjus, interaction.user)
@tree.command(name="adminjail", description=S.CMD["adminjail"])
@app_commands.guild_only()
@app_commands.describe(
kasutaja=S.OPT["admin_kasutaja"],
minutid=S.OPT["adminjail_minutid"],
põhjus=S.OPT["admin_põhjus"],
)
@app_commands.default_permissions(manage_guild=True)
async def cmd_adminjail(interaction: discord.Interaction, kasutaja: discord.Member, minutid: int, põhjus: str):
if minutid <= 0:
await interaction.response.send_message(S.ERR["positive_duration"], ephemeral=True)
return
await economy.do_admin_jail(kasutaja.id, minutid, interaction.user.id, põhjus)
until_ts = cd_ts(datetime.timedelta(minutes=minutid))
await interaction.response.send_message(
S.ADMIN["jail_done"].format(name=kasutaja.display_name, minutes=minutid, ts=until_ts, reason=põhjus),
ephemeral=True,
)
await _dm_user(
bot,
kasutaja.id,
S.ADMIN["jail_dm"].format(minutes=minutid, reason=põhjus, ts=until_ts),
)
log.info("ADMINJAIL %s %dmin (%s) by %s", kasutaja, minutid, põhjus, interaction.user)
@tree.command(name="adminunjail", description=S.CMD["adminunjail"])
@app_commands.guild_only()
@app_commands.describe(kasutaja=S.OPT["admin_kasutaja"])
@app_commands.default_permissions(manage_guild=True)
async def cmd_adminunjail(interaction: discord.Interaction, kasutaja: discord.Member):
await economy.do_admin_unjail(kasutaja.id, interaction.user.id)
await interaction.response.send_message(
S.ADMIN["unjail_done"].format(name=kasutaja.display_name), ephemeral=True
)
await _dm_user(bot, kasutaja.id, S.ADMIN["unjail_dm"])
log.info("ADMINUNJAIL %s by %s", kasutaja, interaction.user)
@tree.command(name="adminban", description=S.CMD["adminban"])
@app_commands.guild_only()
@app_commands.describe(
kasutaja=S.OPT["admin_kasutaja"],
põhjus=S.OPT["admin_põhjus"],
)
@app_commands.default_permissions(manage_guild=True)
async def cmd_adminban(interaction: discord.Interaction, kasutaja: discord.Member, põhjus: str):
if bot.user and kasutaja.id == bot.user.id:
await interaction.response.send_message(S.ERR["admin_ban_bot"], ephemeral=True)
return
await economy.do_admin_ban(kasutaja.id, interaction.user.id, põhjus)
await interaction.response.send_message(
S.ADMIN["ban_done"].format(name=kasutaja.display_name, reason=põhjus),
ephemeral=True,
)
await _dm_user(bot, kasutaja.id, S.ADMIN["ban_dm"].format(reason=põhjus))
log.info("ADMINBAN %s (%s) by %s", kasutaja, põhjus, interaction.user)
@tree.command(name="adminunban", description=S.CMD["adminunban"])
@app_commands.guild_only()
@app_commands.describe(kasutaja=S.OPT["admin_kasutaja"])
@app_commands.default_permissions(manage_guild=True)
async def cmd_adminunban(interaction: discord.Interaction, kasutaja: discord.Member):
await economy.do_admin_unban(kasutaja.id, interaction.user.id)
await interaction.response.send_message(
S.ADMIN["unban_done"].format(name=kasutaja.display_name), ephemeral=True
)
await _dm_user(bot, kasutaja.id, S.ADMIN["unban_dm"])
log.info("ADMINUNBAN %s by %s", kasutaja, interaction.user)
@tree.command(name="adminreset", description=S.CMD["adminreset"])
@app_commands.guild_only()
@app_commands.describe(
kasutaja=S.OPT["admin_kasutaja"],
põhjus=S.OPT["admin_põhjus"],
)
@app_commands.default_permissions(manage_guild=True)
async def cmd_adminreset(interaction: discord.Interaction, kasutaja: discord.Member, põhjus: str):
if bot.user and kasutaja.id == bot.user.id:
await interaction.response.send_message(S.ERR["admin_reset_bot"], ephemeral=True)
return
await economy.do_admin_reset(kasutaja.id, interaction.user.id)
await interaction.response.send_message(
S.ADMIN["reset_done"].format(name=kasutaja.display_name, reason=põhjus),
ephemeral=True,
)
await _dm_user(bot, kasutaja.id, S.ADMIN["reset_dm"].format(reason=põhjus))
log.info("ADMINRESET %s (%s) by %s", kasutaja, põhjus, interaction.user)
@tree.command(name="adminview", description=S.CMD["adminview"])
@app_commands.guild_only()
@app_commands.describe(kasutaja=S.OPT["admin_kasutaja"])
@app_commands.default_permissions(manage_guild=True)
async def cmd_adminview(interaction: discord.Interaction, kasutaja: discord.Member):
res = await economy.do_admin_inspect(kasutaja.id)
data = res["data"]
items_str = ", ".join(data.get("items", [])) or "-"
uses = data.get("item_uses", {})
uses_str = ", ".join(f"{k}:{v}" for k, v in uses.items()) if uses else "-"
jailed = data.get("jailed_until") or "-"
banned = S.ADMINVIEW_UI["banned_yes"] if data.get("eco_banned") else S.ADMINVIEW_UI["banned_no"]
exp = data.get("exp", 0)
level = economy.get_level(exp)
prestige_lvl = data.get("prestige_level", 0)
prestige_pp = data.get("prestige_points", 0)
total_fish = data.get("total_fish_caught", 0)
inv_fish = len(data.get("fish_inventory") or [])
embed = discord.Embed(
title=S.ADMINVIEW_UI["title"].format(name=kasutaja.display_name),
color=0x5865F2,
)
embed.add_field(name=S.ADMINVIEW_UI["f_balance"], value=f"{data.get('balance', 0):,} {economy.COIN}", inline=True)
embed.add_field(name=S.ADMINVIEW_UI["f_exp"], value=S.ADMINVIEW_UI["exp_val"].format(exp=f"{exp:,}", level=level), inline=True)
embed.add_field(name=S.ADMINVIEW_UI["f_streak"], value=str(data.get("daily_streak", 0)), inline=True)
embed.add_field(name=S.ADMINVIEW_UI["f_banned"], value=banned, inline=True)
embed.add_field(name=S.ADMINVIEW_UI["f_jailed"], value=jailed, inline=True)
embed.add_field(name=S.ADMINVIEW_UI["f_prestige"], value=S.ADMINVIEW_UI["prestige_val"].format(level=prestige_lvl, pp=prestige_pp), inline=True)
embed.add_field(name=S.ADMINVIEW_UI["f_fish"], value=S.ADMINVIEW_UI["fish_val"].format(caught=total_fish, inv=inv_fish), inline=True)
embed.add_field(name=S.ADMINVIEW_UI["f_items"], value=items_str, inline=False)
embed.add_field(name=S.ADMINVIEW_UI["f_uses"], value=uses_str, inline=False)
embed.add_field(name=S.ADMINVIEW_UI["f_last_daily"], value=data.get("last_daily") or "-", inline=True)
embed.add_field(name=S.ADMINVIEW_UI["f_last_work"], value=data.get("last_work") or "-", inline=True)
embed.add_field(name=S.ADMINVIEW_UI["f_last_crime"], value=data.get("last_crime") or "-", inline=True)
embed.add_field(name=S.ADMINVIEW_UI["f_last_fish"], value=data.get("last_fish") or "-", inline=True)
embed.set_footer(text=S.ADMINVIEW_UI["footer"].format(uid=kasutaja.id))
await interaction.response.send_message(embed=embed, ephemeral=True)
log.info("ADMINVIEW %s by %s", kasutaja, interaction.user)
@tree.command(name="adminexp", description=S.CMD["adminexp"])
@app_commands.guild_only()
@app_commands.describe(
kasutaja=S.OPT["admin_kasutaja"],
kogus=S.OPT["adminexp_kogus"],
põhjus=S.OPT["admin_põhjus"],
)
@app_commands.default_permissions(manage_guild=True)
async def cmd_adminexp(interaction: discord.Interaction, kasutaja: discord.Member, kogus: int, põhjus: str):
if kogus == 0:
await interaction.response.send_message(S.ERR["admincoins_zero"], ephemeral=True)
return
res = await economy.do_admin_exp(kasutaja.id, kogus, interaction.user.id, põhjus)
verb = f"+{kogus}" if kogus > 0 else str(kogus)
emoji = "📈" if kogus > 0 else "📉"
await interaction.response.send_message(
S.ADMIN["exp_done"].format(
emoji=emoji,
name=kasutaja.display_name,
verb=verb,
exp=f"{res['exp']:,}",
level=res["new_level"],
reason=põhjus,
),
ephemeral=True,
)
await _dm_user(
bot,
kasutaja.id,
S.ADMIN["exp_dm"].format(
emoji=emoji,
verb=verb,
reason=põhjus,
exp=f"{res['exp']:,}",
level=res["new_level"],
),
)
if res["level_changed"]:
member = interaction.guild.get_member(kasutaja.id) if interaction.guild else None
if member:
await apply_level_role(member, res["new_level"], res["old_level"])
log.info("ADMINEXP %s%s (%s) by %s", verb, kasutaja, põhjus, interaction.user)
@tree.command(name="adminitem", description=S.CMD["adminitem"])
@app_commands.guild_only()
@app_commands.describe(
kasutaja=S.OPT["admin_kasutaja"],
ese=S.OPT["adminitem_ese"],
tegevus=S.OPT["adminitem_tegevus"],
)
@app_commands.default_permissions(manage_guild=True)
async def cmd_adminitem(interaction: discord.Interaction, kasutaja: discord.Member, ese: str, tegevus: str):
action = tegevus.strip().lower()
if action not in ("anna", "eemalda"):
await interaction.response.send_message(S.ADMIN["item_invalid"].format(item_id=tegevus), ephemeral=True)
return
action_key = "give" if action == "anna" else "remove"
res = await economy.do_admin_item(kasutaja.id, ese, action_key, interaction.user.id)
if not res["ok"]:
if res["reason"] == "invalid_item":
await interaction.response.send_message(S.ADMIN["item_invalid"].format(item_id=ese), ephemeral=True)
elif res["reason"] == "not_owned":
await interaction.response.send_message(
S.ADMIN["item_not_owned"].format(name=kasutaja.display_name, item_id=ese),
ephemeral=True,
)
else:
await interaction.response.send_message(S.ERR["generic_error"].format(error=res["reason"]), ephemeral=True)
return
item_name = economy.SHOP[ese]["name"]
if action_key == "give":
await interaction.response.send_message(S.ADMIN["item_given"].format(item=item_name, name=kasutaja.display_name), ephemeral=True)
await _dm_user(bot, kasutaja.id, S.ADMIN["item_dm_given"].format(item=item_name))
else:
await interaction.response.send_message(S.ADMIN["item_removed"].format(item=item_name, name=kasutaja.display_name), ephemeral=True)
await _dm_user(bot, kasutaja.id, S.ADMIN["item_dm_removed"].format(item=item_name))
log.info("ADMINITEM %s %s %s by %s", action_key, ese, kasutaja, interaction.user)

883
economy_extra_commands.py Normal file
View File

@@ -0,0 +1,883 @@
from __future__ import annotations
import asyncio
import datetime
import random
import time
from collections.abc import Awaitable, Callable, MutableSet
import discord
from discord import app_commands
import economy
import strings as S
def register_economy_extra_commands(
tree: app_commands.CommandTree,
bot: discord.Client,
coin: Callable[[int], str],
cd_ts: Callable[[datetime.timedelta], str],
parse_amount: Callable[[str, int], tuple[int | None, str | None]],
ensure_level_role: Callable[[discord.Member, int], Awaitable[None]],
active_games: MutableSet[int],
) -> None:
active_heist = None
# -----------------------------------------------------------------------
# /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
if len(participants) == 1:
names = f"**{leader}**"
elif len(participants) == 2:
names = S.HEIST_UI["names_duo"].format(
a=participants[0].display_name,
b=participants[1].display_name,
)
elif len(participants) <= 4:
names = S.HEIST_UI["names_sep"].join(f"**{p.display_name}**" for p in participants)
else:
names = S.HEIST_UI["names_crew"].format(leader=participants[0].display_name)
vehicle = random.choice(story["vehicles"])
approach = random.choice(["sneaky", "loud"])
non_leaders = participants[1:] if len(participants) > 1 else participants
def fill(tmpl: str) -> str:
picked = random.choice(non_leaders).display_name
return tmpl.format(
leader=f"**{leader}**",
member=f"**{picked}**",
names=names,
vehicle=vehicle,
)
getaway_pool = "getaway_success" if success else "getaway_fail"
return [
fill(random.choice(story["arrival"])),
fill(random.choice(story[f"entry_{approach}"])),
fill(random.choice(story["inside"])),
fill(random.choice(story["vault"])),
fill(random.choice(story["vault_open"])),
fill(random.choice(story["police_inbound"])),
fill(random.choice(story[getaway_pool])),
fill(random.choice(story["escape_success" if success else "escape_fail"])),
]
class HeistLobbyView(discord.ui.View):
def __init__(self, organizer: discord.Member, organizer_has_jellyfin: bool = False):
super().__init__(timeout=_HEIST_JOIN_WINDOW)
self.organizer = organizer
self.participants: list[discord.Member] = [organizer]
self.message: discord.Message | None = None
self.resolved = False
self.jellyfin_holders: int = 1 if organizer_has_jellyfin else 0
def _chance(self) -> float:
n = len(self.participants)
base = min(_HEIST_BASE_CHANCE + _HEIST_CHANCE_STEP * (n - 1), _HEIST_MAX_CHANCE)
jelly_bonus = 0.05 if self.jellyfin_holders > 0 else 0.0
return min(base + jelly_bonus, _HEIST_MAX_CHANCE)
def _lobby_embed(self) -> discord.Embed:
names = "\n".join(f"{p.display_name}" for p in self.participants)
desc = S.HEIST_UI["lobby_desc"].format(
n=len(self.participants),
max=_HEIST_MAX_PLAYERS,
names=names,
chance=int(self._chance() * 100),
ts=int(self._timeout_expiry()),
)
return discord.Embed(title=S.TITLE["heist_lobby"], description=desc, color=0xE67E22)
def _timeout_expiry(self) -> float:
return time.time() + (self.timeout or 0)
@discord.ui.button(label=S.HEIST_UI["btn_join"], style=discord.ButtonStyle.danger)
async def join(self, interaction: discord.Interaction, _: discord.ui.Button):
if any(p.id == interaction.user.id for p in self.participants):
await interaction.response.send_message(S.HEIST_UI["already_joined"], ephemeral=True)
return
if len(self.participants) >= _HEIST_MAX_PLAYERS:
await interaction.response.send_message(S.ERR["heist_full"], ephemeral=True)
return
if interaction.user.id in active_games:
await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True)
return
res = await economy.do_heist_check(interaction.user.id)
if not res["ok"]:
if res["reason"] == "banned":
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
elif res["reason"] == "jailed":
await interaction.response.send_message(
S.CD_MSG["jailed"].format(ts=cd_ts(res["remaining"])), ephemeral=True
)
else:
await interaction.response.send_message(
S.CD_MSG["heist"].format(ts=cd_ts(res["remaining"])), ephemeral=True
)
return
self.participants.append(interaction.user)
active_games.add(interaction.user.id)
joiner_data = await economy.get_user(interaction.user.id)
if "jellyfin" in joiner_data.get("items", []):
self.jellyfin_holders += 1
await interaction.response.edit_message(embed=self._lobby_embed())
@discord.ui.button(label=S.HEIST_UI["btn_start"], style=discord.ButtonStyle.success)
async def start_now(self, interaction: discord.Interaction, _: discord.ui.Button):
if interaction.user.id != self.organizer.id:
await interaction.response.send_message(S.HEIST_UI["only_organizer"], ephemeral=True)
return
if len(self.participants) < _HEIST_MIN_PLAYERS:
await interaction.response.send_message(
S.ERR["heist_min_players"].format(min=_HEIST_MIN_PLAYERS), ephemeral=True
)
return
await self._resolve(interaction)
async def _resolve(self, interaction: discord.Interaction | None = None) -> None:
nonlocal 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
success = random.random() < self._chance()
story_lines = _build_heist_story(self.participants, success)
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
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)
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,
)
await economy.set_heist_global_cd(time.time() + _HEIST_GLOBAL_CD)
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):
nonlocal active_heist
if active_heist is not None:
await interaction.response.send_message(S.ERR["heist_active"], ephemeral=True)
return
heist_cd = await economy.get_heist_global_cd()
if time.time() < heist_cd:
await interaction.response.send_message(
S.CD_MSG["heist_global"].format(
ts=cd_ts(datetime.timedelta(seconds=heist_cd - time.time()))
),
ephemeral=True,
)
return
if interaction.user.id in active_games:
await interaction.response.send_message(S.ERR["already_in_game"], ephemeral=True)
return
res = await economy.do_heist_check(interaction.user.id)
if not res["ok"]:
if res["reason"] == "banned":
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
elif res["reason"] == "jailed":
await interaction.response.send_message(
S.CD_MSG["jailed"].format(ts=cd_ts(res["remaining"])), ephemeral=True
)
return
organizer_data = await economy.get_user(interaction.user.id)
view = HeistLobbyView(interaction.user, "jellyfin" in organizer_data.get("items", []))
active_heist = view
active_games.add(interaction.user.id)
await interaction.response.send_message(embed=view._lobby_embed(), view=view)
view.message = await interaction.original_response()
# -----------------------------------------------------------------------
# /jailbreak - Monopoly-style dice escape
# -----------------------------------------------------------------------
_DICE_EMOJI = [
"<:TipiYKS:1483103190491856916>",
"<:TipiKAKS:1483103215841972404>",
"<:TipiKOLM:1483103217846980781>",
"<:TipiNELI:1483103237585240114>",
"<:TipiVIIS:1483103239036469289>",
"<:TipiKUUS:1483103253163020348>",
]
class JailbreakView(discord.ui.View):
MAX_TRIES = 3
def __init__(self, user_id: int):
super().__init__(timeout=120)
self.user_id = user_id
self.tries = 0
self._rolling = False
self._add_roll_btn()
def _add_roll_btn(self):
self.clear_items()
btn = discord.ui.Button(
label=S.JAILBREAK_UI["btn_roll"].format(try_=self.tries + 1, max=self.MAX_TRIES),
style=discord.ButtonStyle.primary,
)
btn.callback = self._on_roll
self.add_item(btn)
async def _on_roll(self, interaction: discord.Interaction):
if interaction.user.id != self.user_id:
await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True)
return
if self._rolling:
await interaction.response.defer()
return
self._rolling = True
self.clear_items()
rolling_embed = discord.Embed(
title=S.TITLE["jailbreak"],
description=S.JAILBREAK_UI["rolling_desc"],
color=0xF4C430,
)
await interaction.response.edit_message(embed=rolling_embed, view=self)
d1 = random.randint(1, 6)
d2 = random.randint(1, 6)
e1, e2 = _DICE_EMOJI[d1 - 1], _DICE_EMOJI[d2 - 1]
double = d1 == d2
self.tries += 1
tries_left = self.MAX_TRIES - self.tries
await asyncio.sleep(1.5)
self._rolling = False
if double:
await economy.do_jail_free(self.user_id)
self.stop()
embed = discord.Embed(
title=S.TITLE["jailbreak_free"],
description=S.JAILBREAK_UI["free_desc"].format(d1=e1, d2=e2),
color=0x57F287,
)
await interaction.edit_original_response(embed=embed, view=self)
elif tries_left == 0:
self.stop()
user_data = await economy.get_user(self.user_id)
bal = user_data["balance"]
if bal >= economy.MIN_BAIL:
min_fine = max(economy.MIN_BAIL, int(bal * 0.20))
max_fine = max(economy.MIN_BAIL, int(bal * 0.30))
desc = S.JAILBREAK_UI["fail_bail_offer"].format(
d1=e1,
d2=e2,
min=coin(min_fine),
max=coin(max_fine),
bal=coin(bal),
)
embed = discord.Embed(
title=S.TITLE["jailbreak_fail"],
description=desc,
color=0xED4245,
)
await interaction.edit_original_response(embed=embed, view=BailView(self.user_id))
else:
embed = discord.Embed(
title=S.TITLE["jailbreak_fail"],
description=S.JAILBREAK_UI["fail_broke_desc"].format(
d1=e1,
d2=e2,
balance=coin(bal),
),
color=0xED4245,
)
await interaction.edit_original_response(embed=embed, view=None)
else:
self._add_roll_btn()
embed = discord.Embed(
title=S.TITLE["jailbreak_miss"].format(tries=self.tries, max=self.MAX_TRIES),
description=S.JAILBREAK_UI["miss_desc"].format(d1=e1, d2=e2, left=tries_left),
color=0xF4C430,
)
await interaction.edit_original_response(embed=embed, view=self)
class BailView(discord.ui.View):
def __init__(self, user_id: int):
super().__init__(timeout=60)
self.user_id = user_id
@discord.ui.button(label=S.JAILBREAK_UI["bail_btn"], style=discord.ButtonStyle.danger)
async def pay_bail(self, interaction: discord.Interaction, _: discord.ui.Button):
if interaction.user.id != self.user_id:
await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True)
return
res = await economy.do_bail(self.user_id)
self.clear_items()
self.stop()
if not res["ok"] and res.get("reason") == "broke":
embed = discord.Embed(
title=S.TITLE["jailbreak_bail"],
description=S.JAILBREAK_UI["bail_broke_desc"].format(
min=coin(economy.MIN_BAIL),
balance=coin(res["balance"]),
),
color=0xED4245,
)
else:
embed = discord.Embed(
title=S.TITLE["jailbreak_bail"],
description=S.JAILBREAK_UI["bail_paid_desc"].format(
fine=coin(res["fine"]),
balance=coin(res["balance"]),
),
color=0x57F287,
)
await interaction.response.edit_message(embed=embed, view=self)
@tree.command(name="jailbreak", description=S.CMD["jailbreak"])
async def cmd_jailbreak(interaction: discord.Interaction):
user_data = await economy.get_user(interaction.user.id)
remaining = economy._is_jailed(user_data)
if not remaining:
await interaction.response.send_message(S.ERR["not_jailed"], ephemeral=True)
return
if user_data.get("jailbreak_used", False):
bal = user_data["balance"]
min_fine = max(economy.MIN_BAIL, int(bal * 0.20))
max_fine = max(economy.MIN_BAIL, int(bal * 0.30))
if bal >= economy.MIN_BAIL:
desc = S.JAILBREAK_UI["already_bail"].format(
min=coin(min_fine),
max=coin(max_fine),
bal=coin(bal),
ts=cd_ts(remaining),
)
await interaction.response.send_message(
embed=discord.Embed(
title=S.TITLE["jailbreak_bail"],
description=desc,
color=0xED4245,
),
view=BailView(interaction.user.id),
ephemeral=True,
)
else:
desc = S.JAILBREAK_UI["already_broke"].format(
min=coin(economy.MIN_BAIL),
bal=coin(bal),
ts=cd_ts(remaining),
)
await interaction.response.send_message(
embed=discord.Embed(
title=S.TITLE["jailbreak_bail"],
description=desc,
color=0xED4245,
),
ephemeral=True,
)
return
await economy.set_jailbreak_used(interaction.user.id)
embed = discord.Embed(
title=S.TITLE["jailbreak"],
description=S.JAILBREAK_UI["intro_desc"].format(
ts=cd_ts(remaining),
tries=JailbreakView.MAX_TRIES,
),
color=0xF4C430,
)
await interaction.response.send_message(
embed=embed,
view=JailbreakView(interaction.user.id),
ephemeral=True,
)
@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 is None or summa_int <= 0:
await interaction.response.send_message(S.ERR["positive_amount"], ephemeral=True)
return
if kasutaja.id == interaction.user.id:
await interaction.response.send_message(S.ERR["give_self"], ephemeral=True)
return
if kasutaja.bot:
await interaction.response.send_message(S.ERR["give_bot"], ephemeral=True)
return
res = await economy.do_give(interaction.user.id, kasutaja.id, summa_int)
if not res["ok"]:
if res["reason"] == "banned":
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
elif res["reason"] == "jailed":
await interaction.response.send_message(
S.ERR["give_jailed"].format(ts=cd_ts(res["remaining"])),
ephemeral=True,
)
else:
await interaction.response.send_message(
S.ERR["broke"].format(bal=coin(data["balance"])),
ephemeral=True,
)
return
embed = discord.Embed(
title=f"{economy.COIN} {S.TITLE['give']}",
description=S.GIVE_UI["desc"].format(
giver=interaction.user.display_name,
amount=coin(summa_int),
receiver=kasutaja.display_name,
),
color=0xF4C430,
)
await interaction.response.send_message(embed=embed)
class LeaderboardView(discord.ui.View):
PER_PAGE = 10
def __init__(self, data: dict, guild: discord.Guild | None, bot_user: discord.ClientUser | None):
super().__init__(timeout=120)
self.data = data
self.guild = guild
self.bot_user = bot_user
self.page = 0
self.mode = "coins"
self.max_page = 0
self._update_buttons()
def _current_list(self) -> list:
return self.data.get(self.mode, [])
def _update_buttons(self):
current = self._current_list()
self.max_page = max(0, (len(current) - 1) // self.PER_PAGE) if current else 0
self.prev_btn.disabled = self.page == 0
self.next_btn.disabled = self.page >= self.max_page
for m, btn in [
("coins", self.coins_btn),
("exp", self.exp_btn),
("season", self.season_btn),
("prestige", self.prestige_btn),
("wagered", self.wagered_btn),
("fish", self.fish_btn),
]:
btn.style = discord.ButtonStyle.primary if m == self.mode else discord.ButtonStyle.secondary
def _name(self, uid: str, highlight_uid: int | None = None) -> str:
if self.guild:
member = self.guild.get_member(int(uid))
name = member.display_name if member else S.LEADERBOARD_UI["unknown_user"].format(uid=uid)
else:
name = S.LEADERBOARD_UI["unknown_user"].format(uid=uid)
if highlight_uid and int(uid) == highlight_uid:
name = f"** {name} **"
return name
def _make_embed(self, highlight_uid: int | None = None) -> discord.Embed:
title_map = {
"coins": f"{economy.COIN} {S.TITLE['leaderboard_coins']}",
"exp": S.TITLE["leaderboard_exp"],
"season": S.TITLE["leaderboard_season"],
"prestige": S.TITLE["leaderboard_prestige"],
"wagered": S.TITLE["leaderboard_wagered"],
"fish": S.TITLE["leaderboard_fish"],
}
color_map = {"coins": 0xF4C430, "wagered": 0xED4245, "fish": 0x57F287}
embed = discord.Embed(
title=title_map.get(self.mode, "Edetabel"),
color=color_map.get(self.mode, 0x5865F2),
)
lines = []
if self.mode == "coins" and self.page == 0 and self.data.get("house_entry"):
_, bal = self.data["house_entry"]
house_name = self.bot_user.display_name if self.bot_user else S.LEADERBOARD_UI["house_default_name"]
lines.append(S.LEADERBOARD_UI["house_entry"].format(name=house_name, balance=coin(bal)))
lines.append("")
start = self.page * self.PER_PAGE
medals = ["🥇", "🥈", "🥉"]
current = self._current_list()
slice_ = current[start : start + self.PER_PAGE]
if not slice_:
lines.append(S.LEADERBOARD_UI["no_entries"])
else:
for i, entry in enumerate(slice_):
rank = start + i
uid = entry[0]
prefix = medals[rank] if rank < 3 else f"**{rank + 1}.**"
name = self._name(uid, highlight_uid)
if self.mode == "coins":
lines.append(f"{prefix} {name} - {coin(entry[1])}")
elif self.mode == "exp":
lines.append(
S.LEADERBOARD_UI["exp_entry"].format(
prefix=prefix,
name=name,
exp=entry[1],
level=entry[2],
)
)
elif self.mode == "season":
lines.append(
S.LEADERBOARD_UI["season_entry"].format(
prefix=prefix,
name=name,
exp=entry[1],
prestige=entry[2],
)
)
elif self.mode == "prestige":
lines.append(
S.LEADERBOARD_UI["prestige_entry"].format(
prefix=prefix,
name=name,
prestige=entry[1],
pp=entry[2],
)
)
elif self.mode == "wagered":
lines.append(
S.LEADERBOARD_UI["wagered_entry"].format(
prefix=prefix,
name=name,
wagered=coin(entry[1]),
)
)
elif self.mode == "fish":
lines.append(
S.LEADERBOARD_UI["fish_entry"].format(
prefix=prefix,
name=name,
caught=entry[1],
)
)
total = self.max_page + 1
embed.description = "\n".join(lines)
embed.set_footer(
text=S.LEADERBOARD_UI["footer"].format(
page=self.page + 1,
total=total,
count=len(current),
)
)
return embed
@discord.ui.button(label="", style=discord.ButtonStyle.secondary, row=0)
async def prev_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
self.page -= 1
self._update_buttons()
await interaction.response.edit_message(embed=self._make_embed(), view=self)
@discord.ui.button(label="", style=discord.ButtonStyle.secondary, row=0)
async def next_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
self.page += 1
self._update_buttons()
await interaction.response.edit_message(embed=self._make_embed(), view=self)
@discord.ui.button(label=S.LEADERBOARD_UI["btn_find_me"], style=discord.ButtonStyle.secondary, row=0)
async def find_me_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
uid = interaction.user.id
for i, entry in enumerate(self._current_list()):
if int(entry[0]) == uid:
self.page = i // self.PER_PAGE
self._update_buttons()
await interaction.response.edit_message(
embed=self._make_embed(highlight_uid=uid),
view=self,
)
return
await interaction.response.send_message(S.ERR["not_in_leaderboard"], ephemeral=True)
@discord.ui.button(label=S.LEADERBOARD_UI["btn_coins"], style=discord.ButtonStyle.primary, row=1)
async def coins_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
self.mode = "coins"
self.page = 0
self._update_buttons()
await interaction.response.edit_message(embed=self._make_embed(), view=self)
@discord.ui.button(label=S.LEADERBOARD_UI["btn_exp"], style=discord.ButtonStyle.secondary, row=1)
async def exp_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
self.mode = "exp"
self.page = 0
self._update_buttons()
await interaction.response.edit_message(embed=self._make_embed(), view=self)
@discord.ui.button(label=S.LEADERBOARD_UI["btn_season"], style=discord.ButtonStyle.secondary, row=1)
async def season_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
self.mode = "season"
self.page = 0
self._update_buttons()
await interaction.response.edit_message(embed=self._make_embed(), view=self)
@discord.ui.button(label=S.LEADERBOARD_UI["btn_prestige"], style=discord.ButtonStyle.secondary, row=1)
async def prestige_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
self.mode = "prestige"
self.page = 0
self._update_buttons()
await interaction.response.edit_message(embed=self._make_embed(), view=self)
@discord.ui.button(label=S.LEADERBOARD_UI["btn_wagered"], style=discord.ButtonStyle.secondary, row=1)
async def wagered_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
self.mode = "wagered"
self.page = 0
self._update_buttons()
await interaction.response.edit_message(embed=self._make_embed(), view=self)
@discord.ui.button(label=S.LEADERBOARD_UI["btn_fish"], style=discord.ButtonStyle.secondary, row=2)
async def fish_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
self.mode = "fish"
self.page = 0
self._update_buttons()
await interaction.response.edit_message(embed=self._make_embed(), view=self)
async def on_timeout(self):
for child in self.children:
child.disabled = True
@tree.command(name="leaderboard", description=S.CMD["leaderboard"])
async def cmd_leaderboard(interaction: discord.Interaction):
await interaction.response.defer()
coins_raw, exp_raw, season_raw, prestige_raw, wagered_raw, fish_raw = await asyncio.gather(
economy.get_leaderboard(top_n=None),
economy.get_leaderboard_exp(top_n=None),
economy.get_leaderboard_season_exp(top_n=None),
economy.get_leaderboard_prestige(top_n=None),
economy.get_leaderboard_wagered(top_n=None),
economy.get_leaderboard_fish(top_n=None),
)
house_entry = None
regular = []
bot_id = bot.user.id if bot.user else None
for uid, bal in coins_raw:
if bot_id and int(uid) == bot_id:
house_entry = (uid, bal)
else:
regular.append((uid, bal))
def _no_bot(entries: list) -> list:
return [e for e in entries if not (bot_id and int(e[0]) == bot_id)]
data = {
"coins": regular,
"exp": _no_bot(exp_raw),
"season": _no_bot(season_raw),
"prestige": _no_bot(prestige_raw),
"wagered": _no_bot(wagered_raw),
"fish": _no_bot(fish_raw),
"house_entry": house_entry,
}
view = LeaderboardView(data, interaction.guild, bot.user)
await interaction.followup.send(embed=view._make_embed(), view=view)
def _shop_embed(tier: int, user_data: dict) -> discord.Embed:
owned = set(user_data.get("items", []))
item_uses = user_data.get("item_uses", {})
tier_names = {1: S.SHOP_UI["tier_1"], 2: S.SHOP_UI["tier_2"], 3: S.SHOP_UI["tier_3"]}
embed = discord.Embed(
title=f"{economy.COIN} TipiBOTi pood · {tier_names[tier]}",
description=S.SHOP_UI["desc"].format(bal=coin(user_data["balance"])),
color=[0x57F287, 0xF4C430, 0xED4245][tier - 1],
)
for item_id in sorted(economy.SHOP_TIERS[tier], key=lambda k: economy.SHOP[k]["cost"]):
item = economy.SHOP[item_id]
anticheat_uses = item_uses.get("anticheat", 0) if item_id == "anticheat" else 0
min_lvl = economy.SHOP_LEVEL_REQ.get(item_id, 0)
user_lvl = economy.get_level(user_data.get("exp", 0))
if item_id in owned and not (item_id == "anticheat" and anticheat_uses <= 0):
if item_id == "anticheat":
key = "owned_uses_1" if anticheat_uses == 1 else "owned_uses_n"
status = S.SHOP_UI[key].format(uses=anticheat_uses)
else:
status = S.SHOP_UI["owned"]
elif min_lvl > 0 and user_lvl < min_lvl:
status = S.SHOP_UI["locked"].format(min_lvl=min_lvl, user_lvl=user_lvl)
else:
status = f"{item['cost']} {economy.COIN}"
embed.add_field(
name=f"{item['emoji']} {item['name']} · {status}",
value=item["description"],
inline=False,
)
return embed
class ShopView(discord.ui.View):
def __init__(self, user_data: dict, tier: int = 1):
super().__init__(timeout=120)
self._user_data = user_data
self._tier = tier
self._update_buttons()
def _update_buttons(self):
self.clear_items()
for t, label in [(1, S.SHOP_BTN[1]), (2, S.SHOP_BTN[2]), (3, S.SHOP_BTN[3])]:
btn = discord.ui.Button(
label=label,
style=discord.ButtonStyle.primary if t == self._tier else discord.ButtonStyle.secondary,
custom_id=f"shop_tier_{t}",
)
btn.callback = self._make_callback(t)
self.add_item(btn)
def _make_callback(self, tier: int):
async def callback(interaction: discord.Interaction):
self._tier = tier
self._update_buttons()
self._user_data = await economy.get_user(interaction.user.id)
await interaction.response.edit_message(
embed=_shop_embed(self._tier, self._user_data),
view=self,
)
return callback
@tree.command(name="shop", description=S.CMD["shop"])
async def cmd_shop(interaction: discord.Interaction):
data = await economy.get_user(interaction.user.id)
await interaction.response.send_message(
embed=_shop_embed(1, data),
view=ShopView(data, tier=1),
ephemeral=True,
)
@tree.command(name="buy", description=S.CMD["buy"])
@app_commands.describe(ese=S.OPT["buy_ese"])
@app_commands.choices(
ese=[
app_commands.Choice(name=f"{v['name']} ({v['cost']} TipiCOINi)", value=k)
for k, v in economy.SHOP.items()
]
)
async def cmd_buy(interaction: discord.Interaction, ese: app_commands.Choice[str]):
res = await economy.do_buy(interaction.user.id, ese.value)
if not res["ok"]:
if res["reason"] == "banned":
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
elif res["reason"] == "owned":
await interaction.response.send_message(S.ERR["item_owned"], ephemeral=True)
elif res["reason"] == "level_required":
await interaction.response.send_message(
S.ERR["item_level_req"].format(
min_level=res["min_level"],
user_level=res["user_level"],
),
ephemeral=True,
)
elif res["reason"] == "insufficient":
await interaction.response.send_message(
S.ERR["broke_need"].format(need=coin(res["need"])),
ephemeral=True,
)
else:
await interaction.response.send_message(S.ERR["item_not_found"], ephemeral=True)
return
item = res["item"]
embed = discord.Embed(
title=S.BUY_UI["title"].format(emoji=item["emoji"], name=item["name"]),
description=S.BUY_UI["desc"].format(
description=item["description"],
balance=coin(res["balance"]),
),
color=0x57F287,
)
await interaction.response.send_message(embed=embed)

380
economy_fish_commands.py Normal file
View File

@@ -0,0 +1,380 @@
from __future__ import annotations
import asyncio
import random
from collections.abc import Awaitable, Callable, MutableSet
import discord
from discord import app_commands
import economy
import strings as S
def register_economy_fish_commands(
tree: app_commands.CommandTree,
coin: Callable[[int], str],
cd_ts: Callable,
check_cmd_rate: Callable[[discord.Interaction], Awaitable[bool]],
maybe_remind: Callable[[int, str], Awaitable[None]],
award_exp: Callable[[discord.Interaction, int], Awaitable[None]],
active_games: MutableSet[int],
) -> None:
class FishCatchView(discord.ui.View):
"""Shown after a successful pull - lets user sell or keep the fish."""
def __init__(self, user_id: int, res: dict, fish_id: str, weight: int):
super().__init__(timeout=60)
self.user_id = user_id
self._res = res
self._fish_id = fish_id
self._weight = weight
self._done = False
def _catch_embed(self, color: int = 0x57F287) -> discord.Embed:
rarity = economy.FISH_CATALOGUE[self._fish_id]["rarity"]
emoji = S.FISH_RARITY_EMOJI[rarity]
fish_name = S.FISH_NAMES[self._fish_id]
desc = S.FISH_UI["catch_desc"].format(
name=fish_name,
weight=self._weight,
exp=self._res["exp"],
value=coin(self._res["value"]),
)
if self._res.get("is_new"):
desc += S.FISH_UI["new_fish"]
return discord.Embed(title=f"{emoji} {fish_name}!", description=desc, color=color)
@discord.ui.button(label="", style=discord.ButtonStyle.success)
async def sell_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
if interaction.user.id != self.user_id:
await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True)
return
if self._done:
await interaction.response.defer()
return
self._done = True
self.stop()
for child in self.children:
child.disabled = True
sell_res = await economy.do_fish_sell(self.user_id, [-1])
rarity = economy.FISH_CATALOGUE[self._fish_id]["rarity"]
emoji = S.FISH_RARITY_EMOJI[rarity]
fish_name = S.FISH_NAMES[self._fish_id]
desc = S.FISH_UI["catch_sold"].format(
name=fish_name,
weight=self._weight,
coins=coin(sell_res["coins"]),
exp=self._res["exp"],
balance=coin(sell_res["balance"]),
)
if self._res.get("is_new"):
desc += S.FISH_UI["new_fish"]
embed = discord.Embed(title=f"{emoji} {fish_name}!", description=desc, color=0x57F287)
await interaction.response.edit_message(embed=embed, view=self)
@discord.ui.button(label="", style=discord.ButtonStyle.secondary)
async def keep_btn(self, interaction: discord.Interaction, button: discord.ui.Button):
if interaction.user.id != self.user_id:
await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True)
return
if self._done:
await interaction.response.defer()
return
self._done = True
self.stop()
for child in self.children:
child.disabled = True
rarity = economy.FISH_CATALOGUE[self._fish_id]["rarity"]
emoji = S.FISH_RARITY_EMOJI[rarity]
fish_name = S.FISH_NAMES[self._fish_id]
desc = S.FISH_UI["catch_kept"].format(
name=fish_name,
weight=self._weight,
exp=self._res["exp"],
)
if self._res.get("is_new"):
desc += S.FISH_UI["new_fish"]
embed = discord.Embed(title=f"{emoji} {fish_name}!", description=desc, color=0x5865F2)
await interaction.response.edit_message(embed=embed, view=self)
async def on_timeout(self):
for child in self.children:
child.disabled = True
class FishingView(discord.ui.View):
BITE_WINDOW = 2.0
def __init__(self, user_id: int, fish_id: str, weight: int):
super().__init__(timeout=40)
self.user_id = user_id
self._fish_id = fish_id
self._weight = weight
self._clicked = False
self._bite_active = False
self._msg: discord.Message | None = None
self.pull_btn = discord.ui.Button(
label=S.FISH_UI["btn_wait"],
style=discord.ButtonStyle.secondary,
disabled=True,
)
self.pull_btn.callback = self._pull
self.add_item(self.pull_btn)
async def start(self, msg: discord.Message) -> None:
self._msg = msg
wait = random.uniform(5, 15)
await asyncio.sleep(wait)
if self._clicked or self.is_finished():
return
self._bite_active = True
self.pull_btn.disabled = False
self.pull_btn.label = S.FISH_UI["btn_bite"]
self.pull_btn.style = discord.ButtonStyle.success
try:
await msg.edit(
embed=discord.Embed(
title=S.TITLE["fish_bite"],
description=S.FISH_UI["bite_desc"],
color=0xED4245,
),
view=self,
)
except Exception:
pass
await asyncio.sleep(self.BITE_WINDOW)
if not self._clicked:
self.stop()
active_games.discard(self.user_id)
self.pull_btn.disabled = True
try:
await msg.edit(
embed=discord.Embed(
title=S.TITLE["fish_escape"],
description=S.FISH_UI["escape_desc"],
color=0x99AAB5,
),
view=self,
)
except Exception:
pass
async def _pull(self, interaction: discord.Interaction) -> None:
if interaction.user.id != self.user_id:
await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True)
return
if not self._bite_active:
await interaction.response.send_message(S.FISH_UI["too_early"], ephemeral=True)
return
self._clicked = True
self.stop()
active_games.discard(self.user_id)
self.pull_btn.disabled = True
await interaction.response.defer()
if self._fish_id == "junk":
junk_text = random.choice(S.FISH_JUNK_LINES)
user_data = await economy.get_user(interaction.user.id)
embed = discord.Embed(
title=S.TITLE["fish_junk"],
description=S.FISH_UI["junk_desc"].format(
text=junk_text,
balance=coin(user_data.get("balance", 0)),
),
color=0x99AAB5,
)
await self._msg.edit(embed=embed, view=self)
return
res = await economy.do_fish_resolve(self.user_id, self._fish_id, self._weight)
if not res["ok"]:
await self._msg.edit(
embed=discord.Embed(title=S.ERR["generic_error"], color=0xED4245),
view=self,
)
return
catch_view = FishCatchView(self.user_id, res, self._fish_id, self._weight)
catch_view.sell_btn.label = S.FISH_UI["btn_sell"]
catch_view.keep_btn.label = S.FISH_UI["btn_keep"]
await self._msg.edit(embed=catch_view._catch_embed(), view=catch_view)
if res.get("exp", 0) > 0:
asyncio.create_task(award_exp(interaction, res["exp"]))
async def on_timeout(self):
for child in self.children:
child.disabled = True
active_games.discard(self.user_id)
@tree.command(name="fish", description=S.CMD["fish"])
@app_commands.guild_only()
async def cmd_fish(interaction: discord.Interaction):
if await check_cmd_rate(interaction):
return
if interaction.user.id in active_games:
await interaction.response.send_message(S.ERR["game_in_progress"], ephemeral=True)
return
res = await economy.do_fish_start(interaction.user.id)
if not res["ok"]:
if res["reason"] == "banned":
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
elif res["reason"] == "cooldown":
await interaction.response.send_message(
S.CD_MSG["fish"].format(ts=cd_ts(res["remaining"])),
ephemeral=True,
)
elif res["reason"] == "jailed":
await interaction.response.send_message(
S.CD_MSG["jailed"].format(ts=cd_ts(res["remaining"])),
ephemeral=True,
)
return
user_data = await economy.get_user(interaction.user.id)
rarity_bump = "kalavork" in user_data.get("items", [])
has_echolood = "echolood" in user_data.get("items", [])
fish_id, weight = economy.roll_fish(rarity_bump=rarity_bump)
active_games.add(interaction.user.id)
view = FishingView(interaction.user.id, fish_id, weight)
if has_echolood:
view.BITE_WINDOW = 3.0
embed = discord.Embed(
title=S.TITLE["fish_cast"],
description=S.FISH_UI["cast_desc"],
color=0x5865F2,
)
await interaction.response.send_message(embed=embed, view=view)
msg = await interaction.original_response()
asyncio.create_task(view.start(msg))
asyncio.create_task(maybe_remind(interaction.user.id, "fish"))
@tree.command(name="fishbook", description=S.CMD["fishbook"])
@app_commands.describe(kasutaja=S.OPT["fishbook_kasutaja"])
async def cmd_fishbook(interaction: discord.Interaction, kasutaja: discord.Member | None = None):
target = kasutaja or interaction.user
res = await economy.do_fishbook(target.id)
book: dict = res["book"]
total = res["total_species"]
caught_count = res["unique_caught"]
if not book:
embed = discord.Embed(
title=S.TITLE["fishbook"],
description=S.FISH_UI["book_empty"],
color=0x5865F2,
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return
inv_counts: dict = res.get("inv_counts", {})
all_fish = list(economy.FISH_CATALOGUE.items())
lines = [S.FISH_UI["book_caught"].format(caught=caught_count, total=total)]
for fish_id, fish_data in all_fish:
rarity = fish_data["rarity"]
emoji = S.FISH_RARITY_EMOJI[rarity]
rarity_name = S.FISH_RARITY_NAMES[rarity]
count = book.get(fish_id, 0)
if count > 0:
n_inv = inv_counts.get(fish_id, 0)
inv_str = S.FISH_UI["book_inv"].format(n=n_inv) if n_inv > 0 else ""
lines.append(
S.FISH_UI["book_yes"].format(
emoji=emoji,
name=S.FISH_NAMES[fish_id],
rarity=rarity_name,
count=count,
inv=inv_str,
)
)
else:
lines.append(S.FISH_UI["book_no"].format(rarity=rarity_name))
embed = discord.Embed(
title=S.TITLE["fishbook"].replace("Kalakogu", f"{target.display_name} kalakogu"),
description="\n".join(lines),
color=0x5865F2,
)
embed.set_footer(
text=S.FISH_UI["book_footer"].format(
page=1,
total_pages=1,
caught=caught_count,
total=total,
)
)
await interaction.response.send_message(embed=embed, ephemeral=True)
@tree.command(name="fishsell", description=S.CMD["fishsell"])
@app_commands.guild_only()
async def cmd_fishsell(interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
user_data = await economy.get_user(interaction.user.id)
inv: list = user_data.get("fish_inventory") or []
if not inv:
embed = discord.Embed(
title=S.TITLE["fishbook"],
description=S.FISH_UI["inv_empty"],
color=0x5865F2,
)
await interaction.followup.send(embed=embed, ephemeral=True)
return
total_value = sum(e["value"] for e in inv)
lines = [S.FISH_UI["inv_header"].format(count=len(inv), total_value=coin(total_value))]
for entry in inv:
fid = entry.get("fish_id", "")
rarity = economy.FISH_CATALOGUE.get(fid, {}).get("rarity", "common")
emoji = S.FISH_RARITY_EMOJI.get(rarity, "🐟")
name = S.FISH_NAMES.get(fid, fid)
lines.append(
S.FISH_UI["inv_entry"].format(
emoji=emoji,
name=name,
weight=entry["weight"],
value=coin(entry["value"]),
)
)
embed = discord.Embed(
title=S.TITLE["fishbook"],
description="\n".join(lines),
color=0x5865F2,
)
sell_all_btn = discord.ui.Button(
label=S.FISH_UI["btn_sell"] + f" ({coin(total_value)})",
style=discord.ButtonStyle.success,
)
async def _sell_all(btn_interaction: discord.Interaction):
if btn_interaction.user.id != interaction.user.id:
await btn_interaction.response.send_message(S.ERR["not_your_menu"], ephemeral=True)
return
sell_view.stop()
for child in sell_view.children:
child.disabled = True
res = await economy.do_fish_sell(interaction.user.id)
if not res["ok"]:
await btn_interaction.response.edit_message(
embed=discord.Embed(description=S.FISH_UI["inv_none"], color=0x99AAB5),
view=sell_view,
)
return
sold_embed = discord.Embed(
title=S.TITLE["fishbook"],
description=S.FISH_UI["inv_sold_all"].format(
count=res["count"],
coins=coin(res["coins"]),
balance=coin(res["balance"]),
),
color=0x57F287,
)
await btn_interaction.response.edit_message(embed=sold_embed, view=sell_view)
sell_all_btn.callback = _sell_all
sell_view = discord.ui.View(timeout=60)
sell_view.add_item(sell_all_btn)
await interaction.followup.send(embed=embed, view=sell_view, ephemeral=True)

1132
economy_games_commands.py Normal file

File diff suppressed because it is too large Load Diff

257
economy_income_commands.py Normal file
View File

@@ -0,0 +1,257 @@
from __future__ import annotations
import asyncio
import datetime
from collections.abc import Awaitable, Callable
import discord
from discord import app_commands
import economy
import strings as S
def register_economy_income_commands(
tree: app_commands.CommandTree,
bot: discord.Client,
coin: Callable[[int], str],
cd_ts: Callable[[datetime.timedelta], str],
check_cmd_rate: Callable[[discord.Interaction], Awaitable[bool]],
maybe_remind: Callable[[int, str], Awaitable[None]],
award_exp: Callable[[discord.Interaction, int], Awaitable[None]],
) -> None:
@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,
)
target_data = await economy.get_user(sihtmärk.id)
if "anticheat" not in target_data.get("items", []):
try:
await sihtmärk.send(S.ROB_UI["anticheat_worn"])
except discord.Forbidden:
pass
else:
embed = discord.Embed(
title=S.TITLE["rob_fail"],
description=S.ROB_UI["fail_desc"].format(
fine=coin(res["fine"]),
balance=coin(res["balance"]),
),
color=0xED4245,
)
await interaction.response.send_message(embed=embed)
asyncio.create_task(maybe_remind(interaction.user.id, "rob"))
if res["success"]:
asyncio.create_task(award_exp(interaction, economy.EXP_REWARDS["rob_win"]))
try:
await sihtmärk.send(
S.ROB_UI["victim_dm"].format(
robber=interaction.user.display_name,
stolen=coin(res["stolen"]),
)
)
except discord.Forbidden:
pass

View File

@@ -0,0 +1,249 @@
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
import discord
from discord import app_commands
import economy
import strings as S
def register_prestige_commands(
tree: app_commands.CommandTree,
check_cmd_rate: Callable[[discord.Interaction], Awaitable[bool]],
ensure_level_role: Callable[[discord.Member, int], Awaitable[None]],
) -> None:
class PrestigeView(discord.ui.View):
def __init__(self, user_id: int, tab: str = "status"):
super().__init__(timeout=60)
self.user_id = user_id
self.tab = tab
async def _rebuild(self, data: dict):
self.clear_items()
pp = data.get("prestige_points", 0)
upgrades: dict = data.get("prestige_upgrades") or {}
exp = data.get("exp", 0)
level = economy.get_level(exp)
for tab_id, label in (("status", S.PRESTIGE_UI["btn_tab_status"]), ("shop", S.PRESTIGE_UI["btn_tab_shop"])):
btn = discord.ui.Button(
label=label,
style=discord.ButtonStyle.primary if tab_id == self.tab else discord.ButtonStyle.secondary,
disabled=(tab_id == self.tab),
row=0,
)
btn.callback = self._switch_tab(tab_id)
self.add_item(btn)
if self.tab == "status" and level >= economy.PRESTIGE_MIN_LEVEL:
confirm_btn = discord.ui.Button(
label=S.PRESTIGE_UI["btn_confirm"],
style=discord.ButtonStyle.danger,
row=1,
)
confirm_btn.callback = self._do_prestige()
cancel_btn = discord.ui.Button(
label=S.PRESTIGE_UI["btn_cancel"],
style=discord.ButtonStyle.secondary,
row=1,
)
cancel_btn.callback = self._do_cancel()
self.add_item(confirm_btn)
self.add_item(cancel_btn)
elif self.tab == "shop":
for uid, item in economy.PRESTIGE_SHOP.items():
cur_level = upgrades.get(uid, 0)
if cur_level >= item["max_level"]:
continue
cost = item["pp_cost"]
btn = discord.ui.Button(
label=S.PRESTIGE_UI["btn_buy_upgrade"].format(emoji=item["emoji"], name=S.PRESTIGE_SHOP_NAMES[uid], cost=cost),
style=discord.ButtonStyle.success if pp >= cost else discord.ButtonStyle.secondary,
disabled=(pp < cost),
row=1,
)
btn.callback = self._buy_upgrade(uid)
self.add_item(btn)
def _build_status_embed(self, data: dict) -> discord.Embed:
exp = data.get("exp", 0)
level = economy.get_level(exp)
pp = data.get("prestige_points", 0)
p_level = data.get("prestige_level", 0)
if level >= economy.PRESTIGE_MIN_LEVEL:
pp_preview = max(1, exp // 1000)
embed = discord.Embed(
title=S.TITLE["prestige_confirm"],
description=S.PRESTIGE_UI["confirm_desc"].format(level=level, exp=exp, pp=pp_preview),
color=0xF4C430,
)
else:
embed = discord.Embed(
title=S.TITLE["prestige_too_low"],
description=S.PRESTIGE_UI["too_low_desc"].format(level=level, required=economy.PRESTIGE_MIN_LEVEL),
color=0xED4245,
)
if p_level > 0:
embed.set_footer(text=S.PRESTIGE_UI["status_footer"].format(level=p_level, pp=pp))
return embed
def _build_shop_embed(self, data: dict) -> discord.Embed:
pp = data.get("prestige_points", 0)
upgrades: dict = data.get("prestige_upgrades") or {}
embed = discord.Embed(
title=S.TITLE["prestige_shop"],
description=S.PRESTIGE_UI["shop_desc"].format(pp=pp),
color=0xF4C430,
)
for uid, item in economy.PRESTIGE_SHOP.items():
cur_level = upgrades.get(uid, 0)
name = f"{item['emoji']} {S.PRESTIGE_SHOP_NAMES[uid]}"
cost_str = S.PRESTIGE_UI["shop_maxed"] if cur_level >= item["max_level"] else S.PRESTIGE_UI["shop_cost_fmt"].format(cost=item["pp_cost"])
level_str = S.PRESTIGE_UI["shop_level_fmt"].format(cur=cur_level, max=item["max_level"])
embed.add_field(name=f"{name} · {level_str} · {cost_str}", value=S.PRESTIGE_SHOP_DESCRIPTIONS[uid], inline=False)
return embed
def _switch_tab(self, tab_id: str):
async def _cb(interaction: discord.Interaction):
if interaction.user.id != self.user_id:
await interaction.response.send_message(S.ERR["not_your_menu"], ephemeral=True)
return
self.tab = tab_id
await interaction.response.defer()
data = await economy.get_user(self.user_id)
await self._rebuild(data)
embed = self._build_status_embed(data) if tab_id == "status" else self._build_shop_embed(data)
await interaction.edit_original_response(embed=embed, view=self)
return _cb
def _do_prestige(self):
async def _cb(interaction: discord.Interaction):
if interaction.user.id != self.user_id:
await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True)
return
await interaction.response.defer()
res = await economy.do_prestige(self.user_id)
self.clear_items()
if not res["ok"]:
embed = discord.Embed(
title=S.TITLE["prestige_too_low"],
description=S.PRESTIGE_UI["too_low_desc"].format(level=res.get("level", 0), required=res.get("required", 30)),
color=0xED4245,
)
else:
embed = discord.Embed(
title=S.TITLE["prestige_success"].format(level=res["prestige_level"]),
description=S.PRESTIGE_UI["success_desc"].format(pp=res["pp_earned"], level=res["prestige_level"], total_pp=res["prestige_points"]),
color=0xF4C430,
)
await interaction.edit_original_response(embed=embed, view=self)
if res.get("ok") and interaction.guild:
member = interaction.guild.get_member(self.user_id)
if member:
asyncio.create_task(ensure_level_role(member, 1))
return _cb
def _do_cancel(self):
async def _cb(interaction: discord.Interaction):
if interaction.user.id != self.user_id:
await interaction.response.send_message(S.ERR["not_your_game"], ephemeral=True)
return
self.clear_items()
await interaction.response.edit_message(view=self)
return _cb
def _buy_upgrade(self, upgrade_id: str):
async def _cb(interaction: discord.Interaction):
if interaction.user.id != self.user_id:
await interaction.response.send_message(S.ERR["not_your_menu"], ephemeral=True)
return
await interaction.response.defer()
res = await economy.do_prestige_buy(self.user_id, upgrade_id)
if not res["ok"]:
if res["reason"] == "insufficient_pp":
err = S.PRESTIGE_UI["buy_no_pp"].format(have=res["have"], need=res["need"])
elif res["reason"] == "maxed":
err = S.PRESTIGE_UI["buy_maxed"]
else:
err = S.ERR["generic_error"].format(error=res["reason"])
await interaction.followup.send(err, ephemeral=True)
return
data = await economy.get_user(self.user_id)
await self._rebuild(data)
embed = self._build_shop_embed(data)
item = economy.PRESTIGE_SHOP[res["upgrade_id"]]
name = f"{item['emoji']} {S.PRESTIGE_SHOP_NAMES[res['upgrade_id']]}"
embed.description = S.PRESTIGE_UI["buy_success_desc"].format(
name=name,
new_level=res["new_level"],
max_level=item["max_level"],
pp=res["pp_remaining"],
) + "\n\n" + (embed.description or "")
await interaction.edit_original_response(embed=embed, view=self)
return _cb
async def on_timeout(self):
self.clear_items()
@tree.command(name="prestige", description=S.CMD["prestige"])
@app_commands.guild_only()
async def cmd_prestige(interaction: discord.Interaction):
if await check_cmd_rate(interaction):
return
data = await economy.get_user(interaction.user.id)
if data.get("eco_banned"):
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
return
view = PrestigeView(interaction.user.id)
await view._rebuild(data)
embed = view._build_status_embed(data)
await interaction.response.send_message(embed=embed, view=view, ephemeral=True)
@tree.command(name="prestigeshop", description=S.CMD["prestigeshop"])
async def cmd_prestigeshop(interaction: discord.Interaction):
data = await economy.get_user(interaction.user.id)
view = PrestigeView(interaction.user.id, tab="shop")
await view._rebuild(data)
embed = view._build_shop_embed(data)
await interaction.response.send_message(embed=embed, view=view, ephemeral=True)
@tree.command(name="prestigebuy", description=S.CMD["prestigebuy"])
@app_commands.describe(upgrade=S.OPT["prestigebuy_upgrade"])
async def cmd_prestigebuy(interaction: discord.Interaction, upgrade: str):
if await check_cmd_rate(interaction):
return
res = await economy.do_prestige_buy(interaction.user.id, upgrade.strip().lower())
if not res["ok"]:
if res["reason"] == "banned":
await interaction.response.send_message(S.MSG_BANNED, ephemeral=True)
elif res["reason"] == "not_found":
await interaction.response.send_message(S.PRESTIGE_UI["buy_not_found"], ephemeral=True)
elif res["reason"] == "maxed":
await interaction.response.send_message(S.PRESTIGE_UI["buy_maxed"], ephemeral=True)
elif res["reason"] == "insufficient_pp":
await interaction.response.send_message(
S.PRESTIGE_UI["buy_no_pp"].format(have=res["have"], need=res["need"]),
ephemeral=True,
)
return
item = economy.PRESTIGE_SHOP[res["upgrade_id"]]
name = f"{item['emoji']} {S.PRESTIGE_SHOP_NAMES[res['upgrade_id']]}"
embed = discord.Embed(
title=S.TITLE["prestige_buy_ok"],
description=S.PRESTIGE_UI["buy_success_desc"].format(
name=name,
new_level=res["new_level"],
max_level=res["max_level"],
pp=res["pp_remaining"],
),
color=0x57F287,
)
await interaction.response.send_message(embed=embed, ephemeral=True)

545
economy_profile_commands.py Normal file
View File

@@ -0,0 +1,545 @@
from __future__ import annotations
import asyncio
import datetime
from collections.abc import Awaitable, Callable
import discord
from discord import app_commands
import economy
import strings as S
def register_economy_profile_commands(
tree: app_commands.CommandTree,
coin: Callable[[int], str],
cd_ts: Callable[[datetime.timedelta], str],
ensure_level_role: Callable[[discord.Member, int], Awaitable[None]],
) -> None:
def _profile_main_embed(target: discord.User | discord.Member, data: dict) -> discord.Embed:
exp = data.get("exp", 0)
level = economy.get_level(exp)
role_name = economy.level_role_name(level)
next_level = level + 1
exp_this = economy.exp_for_level(level)
exp_next = economy.exp_for_level(next_level)
progress = exp - exp_this
needed = exp_next - exp_this
pct = progress / needed if needed > 0 else 1.0
filled = int(pct * 12)
bar = "" * filled + "" * (12 - filled)
embed = discord.Embed(
title=S.PROFILE_UI["main_title"].format(name=target.display_name),
color=0xF4C430,
)
embed.add_field(name=S.PROFILE_UI["f_balance"], value=coin(data.get("balance", 0)), inline=True)
embed.add_field(
name=S.PROFILE_UI["f_level"],
value=S.PROFILE_UI["level_val"].format(level=level, role=role_name),
inline=True,
)
streak = data.get("daily_streak", 0)
if streak:
embed.add_field(
name=S.PROFILE_UI["f_streak"],
value=S.BALANCE_UI["streak_val"].format(streak=streak),
inline=True,
)
p_level = data.get("prestige_level", 0)
if p_level > 0:
p_pp = data.get("prestige_points", 0)
embed.add_field(
name=S.PROFILE_UI["f_prestige"],
value=S.PROFILE_UI["prestige_val"].format(level=p_level, pp=p_pp),
inline=True,
)
jail_remaining = economy._is_jailed(data)
if jail_remaining:
embed.add_field(name=S.PROFILE_UI["f_jail"], value=cd_ts(jail_remaining), inline=True)
embed.add_field(
name=S.PROFILE_UI["f_progress"].format(next=next_level),
value=S.PROFILE_UI["progress_bar"].format(bar=bar, done=progress, needed=needed),
inline=False,
)
if level < 10:
embed.set_footer(text=S.PROFILE_UI["footer_t1"])
elif level < 20:
embed.set_footer(text=S.PROFILE_UI["footer_t2"])
else:
embed.set_footer(text=S.PROFILE_UI["footer_t3"])
return embed
def _profile_items_embed(target: discord.User | discord.Member, data: dict) -> discord.Embed:
embed = discord.Embed(
title=S.PROFILE_UI["items_title"].format(name=target.display_name),
color=0xF4C430,
)
uses_map = data.get("item_uses", {})
item_lines = []
for item_id in data.get("items", []):
if item_id not in economy.SHOP:
continue
line = f"{economy.SHOP[item_id]['emoji']} **{economy.SHOP[item_id]['name']}**"
if item_id in uses_map:
uses = uses_map[item_id]
line += (
S.BALANCE_UI["uses_one"].format(uses=uses)
if uses == 1
else S.BALANCE_UI["uses_many"].format(uses=uses)
)
item_lines.append(line)
embed.description = "\n".join(item_lines) if item_lines else S.PROFILE_UI["items_empty"]
return embed
def _profile_stats_embed(target: discord.User | discord.Member, data: dict) -> discord.Embed:
def _s(key: str) -> int:
return data.get(key, 0)
embed = discord.Embed(
title=S.PROFILE_UI["stats_title"].format(name=target.display_name),
color=0x5865F2,
)
embed.add_field(
name=S.STATS_UI["economy_field"],
value=S.STATS_UI["economy_val"].format(
peak=coin(_s("peak_balance")),
earned=coin(_s("lifetime_earned")),
lost=coin(_s("lifetime_lost")),
),
inline=True,
)
embed.add_field(
name=S.STATS_UI["work_field"],
value=S.STATS_UI["work_val"].format(work=_s("work_count"), beg=_s("beg_count")),
inline=True,
)
embed.add_field(name="\u200b", value="\u200b", inline=False)
embed.add_field(
name=S.STATS_UI["gamble_field"],
value=S.STATS_UI["gamble_val"].format(
wagered=coin(_s("total_wagered")),
win=coin(_s("biggest_win")),
loss=coin(_s("biggest_loss")),
jackpots=_s("slots_jackpots"),
),
inline=True,
)
embed.add_field(
name=S.STATS_UI["crime_field"],
value=S.STATS_UI["crime_val"].format(
crimes=_s("crimes_attempted"),
succeeded=_s("crimes_succeeded"),
heists=_s("heists_joined"),
heists_won=_s("heists_won"),
jailed=_s("times_jailed"),
bail=coin(_s("total_bail_paid")),
),
inline=True,
)
embed.add_field(name="\u200b", value="\u200b", inline=False)
embed.add_field(
name=S.STATS_UI["social_field"],
value=S.STATS_UI["social_val"].format(
given=coin(_s("total_given")),
received=coin(_s("total_received")),
),
inline=True,
)
embed.add_field(
name=S.STATS_UI["records_field"],
value=S.STATS_UI["records_val"].format(streak=_s("best_daily_streak")),
inline=True,
)
return embed
def _profile_fish_embed(target: discord.User | discord.Member, fish_res: dict) -> discord.Embed:
embed = discord.Embed(
title=S.PROFILE_UI["fish_title"].format(name=target.display_name),
color=0x5865F2,
)
book: dict = fish_res["book"]
if not book:
embed.description = S.FISH_UI["book_empty"]
return embed
inv_counts: dict = fish_res.get("inv_counts", {})
caught_count = fish_res["unique_caught"]
total = fish_res["total_species"]
lines = [S.FISH_UI["book_caught"].format(caught=caught_count, total=total)]
for fish_id, fish_data in economy.FISH_CATALOGUE.items():
rarity = fish_data["rarity"]
emoji = S.FISH_RARITY_EMOJI[rarity]
rarity_name = S.FISH_RARITY_NAMES[rarity]
count = book.get(fish_id, 0)
if count > 0:
n_inv = inv_counts.get(fish_id, 0)
inv_str = S.FISH_UI["book_inv"].format(n=n_inv) if n_inv > 0 else ""
lines.append(
S.FISH_UI["book_yes"].format(
emoji=emoji,
name=S.FISH_NAMES[fish_id],
rarity=rarity_name,
count=count,
inv=inv_str,
)
)
else:
lines.append(S.FISH_UI["book_no"].format(rarity=rarity_name))
embed.description = "\n".join(lines)
embed.set_footer(
text=S.FISH_UI["book_footer"].format(
page=1,
total_pages=1,
caught=caught_count,
total=total,
)
)
return embed
class ProfileView(discord.ui.View):
def __init__(self, target: discord.User | discord.Member, invoker_id: int, tab: str = "main"):
super().__init__(timeout=120)
self.target = target
self.invoker_id = invoker_id
self.tab = tab
self._rebuild()
def _rebuild(self):
self.clear_items()
tabs = [
("main", S.PROFILE_UI["btn_profile"]),
("items", S.PROFILE_UI["btn_items"]),
("stats", S.PROFILE_UI["btn_stats"]),
("fish", S.PROFILE_UI["btn_fish"]),
]
for tab_id, label in tabs:
btn = discord.ui.Button(
label=label,
style=(
discord.ButtonStyle.primary
if tab_id == self.tab
else discord.ButtonStyle.secondary
),
disabled=(tab_id == self.tab),
)
btn.callback = self._make_cb(tab_id)
self.add_item(btn)
def _make_cb(self, tab_id: str):
async def _cb(interaction: discord.Interaction):
if interaction.user.id != self.invoker_id:
await interaction.response.send_message(S.ERR["not_your_menu"], ephemeral=True)
return
self.tab = tab_id
self._rebuild()
await interaction.response.defer()
data = await economy.get_user(self.target.id)
if tab_id == "fish":
fish_res = await economy.do_fishbook(self.target.id)
embed = _profile_fish_embed(self.target, fish_res)
inv: list = data.get("fish_inventory") or []
if inv and self.target.id == self.invoker_id:
total_value = sum(entry.get("value", 0) for entry in inv)
sell_btn = discord.ui.Button(
label=(
f"{S.FISH_UI['btn_sell']} "
f"({len(inv)} kala · {total_value:,} {economy.COIN})"
),
style=discord.ButtonStyle.success,
row=1,
)
sell_btn.callback = self._sell_fish_cb()
self.add_item(sell_btn)
elif tab_id == "items":
embed = _profile_items_embed(self.target, data)
elif tab_id == "stats":
embed = _profile_stats_embed(self.target, data)
else:
embed = _profile_main_embed(self.target, data)
await interaction.edit_original_response(embed=embed, view=self)
return _cb
def _sell_fish_cb(self):
async def _cb(interaction: discord.Interaction):
if interaction.user.id != self.invoker_id:
await interaction.response.send_message(S.ERR["not_your_menu"], ephemeral=True)
return
await interaction.response.defer()
res = await economy.do_fish_sell(self.invoker_id)
self.tab = "fish"
self._rebuild()
fish_res = await economy.do_fishbook(self.target.id)
embed = _profile_fish_embed(self.target, fish_res)
sold_line = S.FISH_UI["inv_sold_all"].format(
count=res.get("count", 0),
coins=coin(res.get("coins", 0)),
balance=coin(res.get("balance", 0)),
)
embed.description = f"{sold_line}\n\n{embed.description or ''}"
await interaction.edit_original_response(embed=embed, view=self)
return _cb
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_remaining = economy._is_jailed(data)
if jail_remaining:
embed.add_field(name=S.BALANCE_UI["jailed_until"], value=cd_ts(jail_remaining), inline=True)
item_lines = []
uses_map = data.get("item_uses", {})
for item_id in data.get("items", []):
if item_id not in economy.SHOP:
continue
line = f"{economy.SHOP[item_id]['emoji']} {economy.SHOP[item_id]['name']}"
if item_id in uses_map:
uses = uses_map[item_id]
line += (
S.BALANCE_UI["uses_one"].format(uses=uses)
if uses == 1
else S.BALANCE_UI["uses_many"].format(uses=uses)
)
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="profile", description=S.CMD["profile"])
@app_commands.describe(kasutaja=S.OPT["profile_kasutaja"])
async def cmd_profile(interaction: discord.Interaction, kasutaja: discord.Member | None = None):
target = kasutaja or interaction.user
data = await economy.get_user(target.id)
embed = _profile_main_embed(target, data)
invoker_id = interaction.user.id
await interaction.response.send_message(embed=embed, view=ProfileView(target, invoker_id))
if not kasutaja and interaction.guild:
member = interaction.guild.get_member(target.id)
if member:
asyncio.create_task(ensure_level_role(member, economy.get_level(data.get("exp", 0))))
@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, cooldown: 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 + cooldown
if expires <= now:
return S.COOLDOWNS_UI["ready"]
ts = int(expires.timestamp())
return f"⏳ <t:{ts}:R>"
work_cd = datetime.timedelta(minutes=40) if "monitor" in items else economy.COOLDOWNS["work"]
beg_cd = datetime.timedelta(minutes=3) if "hiirematt" in items else economy.COOLDOWNS["beg"]
daily_cd = datetime.timedelta(hours=18) if "korvaklapid" in items else economy.COOLDOWNS["daily"]
fish_cd = datetime.timedelta(seconds=90) if "ussipurk" in items else economy.COOLDOWNS["fish"]
lines = [
S.COOLDOWNS_UI["daily_line"].format(
status=_status("last_daily", daily_cd),
note=S.COOLDOWNS_UI["note_korvak"] if "korvaklapid" in items else "",
),
S.COOLDOWNS_UI["work_line"].format(
status=_status("last_work", work_cd),
note=S.COOLDOWNS_UI["note_monitor"] if "monitor" in items else "",
),
S.COOLDOWNS_UI["beg_line"].format(
status=_status("last_beg", beg_cd),
note=S.COOLDOWNS_UI["note_hiirematt"] if "hiirematt" in items else "",
),
S.COOLDOWNS_UI["crime_line"].format(status=_status("last_crime", economy.COOLDOWNS["crime"])),
S.COOLDOWNS_UI["rob_line"].format(status=_status("last_rob", economy.COOLDOWNS["rob"])),
S.COOLDOWNS_UI["fish_line"].format(
status=_status("last_fish", fish_cd),
note=S.COOLDOWNS_UI["note_ussipurk"] if "ussipurk" in items else "",
),
]
jailed = data.get("jailed_until")
if jailed:
jail_dt = datetime.datetime.fromisoformat(jailed)
if jail_dt > now:
ts = int(jail_dt.timestamp())
lines.append(S.COOLDOWNS_UI["jailed"].format(ts=ts))
else:
lines.append(S.COOLDOWNS_UI["jail_expired"])
embed = discord.Embed(
title=S.TITLE["cooldowns"],
description="\n".join(lines),
color=0x5865F2,
)
await interaction.response.send_message(embed=embed, ephemeral=True)
@tree.command(name="jailed", description=S.CMD["jailed"])
@app_commands.guild_only()
async def cmd_jailed(interaction: discord.Interaction):
await interaction.response.defer()
jailed = await economy.do_get_jailed()
if not jailed:
embed = discord.Embed(
title=S.JAILED_UI["title"],
description=S.JAILED_UI["empty"],
color=0x57F287,
)
await interaction.followup.send(embed=embed)
return
now = datetime.datetime.now(datetime.timezone.utc)
lines = []
for uid, remaining in jailed:
ts = int((now + remaining).timestamp())
member = interaction.guild.get_member(uid) if interaction.guild else None
mention = member.mention if member else f"<@{uid}>"
lines.append(S.JAILED_UI["entry"].format(mention=mention, ts=ts))
plural = "" if len(jailed) == 1 else "i"
embed = discord.Embed(
title=S.JAILED_UI["title"],
description="\n".join(lines),
color=0xED4245,
)
embed.set_footer(text=S.JAILED_UI["footer"].format(count=len(jailed), plural=plural))
await interaction.followup.send(embed=embed)
@tree.command(name="rank", description=S.CMD["rank"])
@app_commands.describe(kasutaja=S.OPT["rank_kasutaja"])
async def cmd_rank(interaction: discord.Interaction, kasutaja: discord.Member | None = None):
target = kasutaja or interaction.user
data = await economy.get_user(target.id)
exp = data.get("exp", 0)
level = economy.get_level(exp)
role_name = economy.level_role_name(level)
next_level = level + 1
exp_this = economy.exp_for_level(level)
exp_next = economy.exp_for_level(next_level)
progress = exp - exp_this
needed = exp_next - exp_this
pct = progress / needed if needed > 0 else 1.0
filled = int(pct * 12)
bar = "" * filled + "" * (12 - filled)
embed = discord.Embed(
title=S.RANK_UI["title"].format(name=target.display_name, level=level),
color=0x5865F2,
)
embed.add_field(name=S.RANK_UI["field_title"], value=f"**{role_name}**", inline=True)
embed.add_field(name=S.RANK_UI["field_exp"], value=str(exp), inline=True)
embed.add_field(
name=S.RANK_UI["field_progress"].format(next=next_level),
value=S.RANK_UI["progress_val"].format(bar=bar, progress=progress, needed=needed),
inline=False,
)
p_level = data.get("prestige_level", 0)
p_pp = data.get("prestige_points", 0)
s_exp = data.get("season_total_exp", 0)
if p_level > 0 or s_exp > 0:
embed.add_field(
name="\u200b",
value=(
S.PRESTIGE_UI["rank_line"].format(level=p_level, pp=p_pp)
+ "\n"
+ S.PRESTIGE_UI["rank_season"].format(exp=s_exp)
),
inline=False,
)
if level < 10:
embed.set_footer(text=S.RANK_UI["footer_t1"])
elif level < 20:
embed.set_footer(text=S.RANK_UI["footer_t2"])
else:
embed.set_footer(text=S.RANK_UI["footer_t3"])
await interaction.response.send_message(embed=embed, ephemeral=True)
if not kasutaja and interaction.guild:
member = interaction.guild.get_member(target.id)
if member:
asyncio.create_task(ensure_level_role(member, level))
@tree.command(name="stats", description=S.CMD["stats"])
@app_commands.describe(kasutaja=S.OPT["stats_kasutaja"])
async def cmd_stats(interaction: discord.Interaction, kasutaja: discord.Member | None = None):
target = kasutaja or interaction.user
data = await economy.get_user(target.id)
def _s(key: str) -> int:
return data.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)

216
economy_support_commands.py Normal file
View File

@@ -0,0 +1,216 @@
from __future__ import annotations
from collections.abc import Callable
import discord
from discord import app_commands
import economy
import strings as S
def register_economy_support_commands(
tree: app_commands.CommandTree,
parse_amount: Callable[[str, int], tuple[int | None, str | None]],
coin: Callable[[int], str],
cancel_reminder_task: Callable[[int, str], None],
) -> None:
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()
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:
cancel_reminder_task(self.user_id, cmd)
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,
)

11386
logs/bot.log

File diff suppressed because it is too large Load Diff

View File

@@ -1,108 +0,0 @@
2026-04-01 18:44:29 | WORK user=272518654715887618 earned=+54 lucky=False bal=24077
2026-04-01 20:02:11 | BEG user=272518654715887618 earned=+23 jailed=False bal=24100
2026-04-01 20:02:53 | ROULETTE_WIN user=178852380018868224 bet=15214708 colour=punane result=punane mult=1 bal=30429416
2026-04-01 20:03:42 | ROULETTE_WIN user=178852380018868224 bet=30429416 colour=must result=must mult=1 bal=60858832
2026-04-01 20:04:16 | ROULETTE_LOSE user=178852380018868224 bet=60858832 colour=punane result=must mult=1 bal=0
2026-04-01 20:06:37 | BEG user=401373976431165449 earned=+52 jailed=False bal=8446
2026-04-01 20:06:39 | WORK user=401373976431165449 earned=+60 lucky=False bal=8506
2026-04-01 20:06:52 | DAILY user=401373976431165449 earned=+750 streak=1 bal=9256
2026-04-01 20:07:07 | DAILY user=272518654715887618 earned=+825 streak=1 bal=24925
2026-04-01 20:07:12 | CRIME_WIN user=272518654715887618 earned=+331 bal=25256
2026-04-01 20:07:38 | CRIME_WIN user=401373976431165449 earned=+391 bal=9647
2026-04-01 20:07:49 | BUY user=401373976431165449 item=echolood cost=-8000 bal=1647
2026-04-01 20:09:16 | ROB_BLOCKED robber=824516445382901800 victim=340451525799182357 fine=-118 robber_bal=891 ac_uses_left=1
2026-04-01 20:09:23 | ROB_BLOCKED robber=401373976431165449 victim=340451525799182357 fine=-175 robber_bal=1472 ac_uses_left=0
2026-04-01 20:09:52 | ROB_WIN robber=178852380018868224 victim=340451525799182357 stolen=+34868 jackpot=False robber_bal=34868 victim_bal=140238
2026-04-01 20:10:48 | DAILY user=367347301322326016 earned=+712 streak=1 bal=8462
2026-04-01 20:10:55 | WORK user=367347301322326016 earned=+25 lucky=False bal=8487
2026-04-01 20:11:00 | BEG user=367347301322326016 earned=+15 jailed=False bal=8502
2026-04-01 20:11:37 | ROB_FAIL robber=272518654715887618 victim=340451525799182357 fine=-140 robber_bal=25116
2026-04-01 20:15:19 | HEIST_FAIL user=178852380018868224 fine=-1000 jailed_until=2026-04-01T18:45:19.705842+00:00 bal=33868
2026-04-01 20:15:19 | HEIST_FAIL user=340451525799182357 fine=-1000 jailed_until=2026-04-01T18:45:19.705842+00:00 bal=139238
2026-04-01 20:15:19 | HEIST_FAIL user=272518654715887618 fine=-1000 jailed_until=2026-04-01T18:45:19.705842+00:00 bal=24116
2026-04-01 20:15:19 | HEIST_FAIL user=209554152584380420 fine=-1000 jailed_until=2026-04-01T18:45:19.705842+00:00 bal=20112
2026-04-01 20:15:19 | HEIST_FAIL user=401373976431165449 fine=-220 jailed_until=2026-04-01T18:45:19.705842+00:00 bal=1252
2026-04-01 20:15:19 | HEIST_FAIL user=344531774518591498 fine=-1000 jailed_until=2026-04-01T18:45:19.705842+00:00 bal=112701
2026-04-01 20:15:19 | HEIST_FAIL user=367347301322326016 fine=-1000 jailed_until=2026-04-01T18:45:19.705842+00:00 bal=7502
2026-04-01 20:15:45 | JAIL_FREE user=272518654715887618 method=doubles
2026-04-01 20:20:07 | JAIL_FREE user=344531774518591498 method=doubles
2026-04-01 20:20:14 | DAILY user=344531774518591498 earned=+825 streak=1 bal=113526
2026-04-01 20:20:16 | WORK user=344531774518591498 earned=+45 lucky=False bal=113571
2026-04-01 20:20:19 | WORK user=272518654715887618 earned=+55 lucky=False bal=24171
2026-04-01 20:20:19 | BEG user=344531774518591498 earned=+22 jailed=False bal=113593
2026-04-01 20:20:36 | BLACKJACK user=272518654715887618 payout=+0 net=-24171 bal=0
2026-04-01 20:21:03 | CRIME_FAIL user=344531774518591498 fine=-90 jailed=True bal=113503
2026-04-01 20:21:11 | FISH user=272518654715887618 fish=koger weight=590 value=15
2026-04-01 20:21:45 | ROB_WIN robber=344531774518591498 victim=340451525799182357 stolen=+15566 jackpot=False robber_bal=129069 victim_bal=123672
2026-04-01 20:25:40 | BAIL_PAID user=178852380018868224 fine=-8760 pct=26% bal=25108
2026-04-01 20:28:28 | BEG user=178852380018868224 earned=+28 jailed=False bal=25136
2026-04-01 20:28:30 | WORK user=178852380018868224 earned=+92 lucky=False bal=25228
2026-04-01 20:28:33 | DAILY user=178852380018868224 earned=+825 streak=1 bal=26053
2026-04-01 20:28:38 | CRIME_WIN user=178852380018868224 earned=+640 bal=26693
2026-04-01 20:35:35 | BEG user=401373976431165449 earned=+56 jailed=True bal=1308
2026-04-01 20:36:20 | JAIL_FREE user=401373976431165449 method=doubles
2026-04-01 20:37:47 | FISH user=401373976431165449 fish=angerjas weight=989 value=79
2026-04-01 20:46:02 | DAILY user=338622999127261185 earned=+300 streak=1 bal=300
2026-04-01 20:56:03 | BEG user=344531774518591498 earned=+60 jailed=False bal=129129
2026-04-01 20:56:29 | ROULETTE_LOSE user=344531774518591498 bet=1000 colour=punane result=must mult=1 bal=128129
2026-04-01 21:01:23 | BEG user=272518654715887618 earned=+33 jailed=False bal=33
2026-04-01 21:01:42 | FISH user=272518654715887618 fish=sarj weight=151 value=6
2026-04-01 21:02:38 | FISH user=344531774518591498 fish=viidikas weight=98 value=6
2026-04-01 21:02:57 | FISH_SELL user=344531774518591498 count=2 coins=+13 bal=128142
2026-04-01 21:03:10 | BEG user=401373976431165449 earned=+54 jailed=False bal=1362
2026-04-01 21:03:31 | FISH user=401373976431165449 fish=siig weight=584 value=63
2026-04-01 21:05:19 | WORK user=401373976431165449 earned=+131 lucky=False bal=1493
2026-04-01 21:05:38 | FISH user=401373976431165449 fish=siig weight=1624 value=112
2026-04-01 21:05:48 | FISH_SELL user=401373976431165449 count=3 coins=+254 bal=1747
2026-04-01 21:08:31 | BEG user=401373976431165449 earned=+32 jailed=False bal=1779
2026-04-01 21:08:45 | FISH user=401373976431165449 fish=tougjas weight=3051 value=207
2026-04-01 21:09:01 | ROULETTE_WIN user=401373976431165449 bet=1000 colour=punane result=punane mult=1 bal=2779
2026-04-01 21:09:35 | BLACKJACK user=401373976431165449 payout=+3000 net=+1500 bal=4279
2026-04-01 21:09:35 | ROULETTE_LOSE user=344531774518591498 bet=1000 colour=punane result=must mult=1 bal=127142
2026-04-01 21:10:05 | ROULETTE_WIN user=401373976431165449 bet=1000 colour=punane result=punane mult=1 bal=5279
2026-04-01 21:10:46 | ROULETTE_WIN user=344531774518591498 bet=1000 colour=punane result=punane mult=1 bal=128142
2026-04-01 21:11:03 | ROULETTE_LOSE user=401373976431165449 bet=1000 colour=must result=punane mult=1 bal=4279
2026-04-01 21:11:24 | ROULETTE_LOSE user=344531774518591498 bet=1000 colour=punane result=roheline mult=1 bal=127142
2026-04-01 21:15:50 | WORK user=338622999127261185 earned=+15 lucky=False bal=315
2026-04-01 21:15:54 | CRIME_WIN user=338622999127261185 earned=+453 bal=768
2026-04-01 21:16:00 | BEG user=338622999127261185 earned=+20 jailed=False bal=788
2026-04-01 21:16:13 | FISH user=338622999127261185 fish=ahven weight=422 value=14
2026-04-01 21:18:36 | BEG user=401373976431165449 earned=+20 jailed=False bal=4299
2026-04-01 21:18:52 | FISH user=401373976431165449 fish=karpkala weight=1920 value=47
2026-04-01 21:20:58 | SLOTS_TRIPLE user=344531774518591498 bet=1000 change=4000 bal=131142
2026-04-01 21:21:39 | SLOTS_MISS user=344531774518591498 bet=1000 change=-1000 bal=130142
2026-04-01 21:28:25 | ROULETTE_LOSE user=401373976431165449 bet=1000 colour=punane result=must mult=1 bal=3299
2026-04-01 21:29:10 | SLOTS_PAIR user=344531774518591498 bet=1000 change=500 bal=130642
2026-04-01 21:29:28 | ROULETTE_LOSE user=401373976431165449 bet=1000 colour=punane result=must mult=1 bal=2299
2026-04-01 21:30:49 | SLOTS_PAIR user=344531774518591498 bet=1000 change=500 bal=131142
2026-04-01 21:31:30 | SLOTS_PAIR user=344531774518591498 bet=1000 change=500 bal=131642
2026-04-01 21:31:33 | ROULETTE_WIN user=401373976431165449 bet=1000 colour=punane result=punane mult=1 bal=3299
2026-04-01 21:31:37 | BEG user=401373976431165449 earned=+68 jailed=False bal=3367
2026-04-01 21:32:05 | FISH user=401373976431165449 fish=latikas weight=2351 value=66
2026-04-01 21:33:14 | ROULETTE_LOSE user=401373976431165449 bet=1000 colour=punane result=must mult=1 bal=2367
2026-04-01 21:35:09 | SLOTS_MISS user=344531774518591498 bet=1000 change=-1000 bal=130642
2026-04-01 21:35:14 | ROULETTE_LOSE user=401373976431165449 bet=1000 colour=punane result=must mult=1 bal=1367
2026-04-01 21:48:35 | ROB_FAIL robber=338622999127261185 victim=340451525799182357 fine=-237 robber_bal=551
2026-04-01 21:49:59 | BEG user=401373976431165449 earned=+56 jailed=False bal=1423
2026-04-01 21:50:15 | FISH user=401373976431165449 fish=vimb weight=856 value=462
2026-04-01 22:11:02 | BEG user=401373976431165449 earned=+52 jailed=False bal=1475
2026-04-01 22:11:13 | WORK user=401373976431165449 earned=+73 lucky=False bal=1548
2026-04-01 22:11:27 | ROB_WIN robber=401373976431165449 victim=367347301322326016 stolen=+1818 jackpot=False robber_bal=3366 victim_bal=5684
2026-04-01 22:11:44 | FISH user=401373976431165449 fish=lohe weight=2973 value=313
2026-04-01 22:13:30 | ROULETTE_LOSE user=401373976431165449 bet=2000 colour=punane result=roheline mult=1 bal=1366
2026-04-01 22:14:33 | ROULETTE_LOSE user=401373976431165449 bet=1366 colour=punane result=must mult=1 bal=0
2026-04-01 22:40:19 | WORK user=367347301322326016 earned=+82 lucky=False bal=5766
2026-04-01 22:40:23 | CRIME_FAIL user=367347301322326016 fine=-100 jailed=True bal=5666
2026-04-01 22:46:27 | WORK user=344531774518591498 earned=+116 lucky=False bal=130758
2026-04-01 22:46:30 | BEG user=344531774518591498 earned=+58 jailed=False bal=130816
2026-04-01 22:46:35 | CRIME_WIN user=344531774518591498 earned=+419 bal=131235
2026-04-01 22:46:44 | ROB_FAIL robber=344531774518591498 victim=340451525799182357 fine=-246 robber_bal=130989
2026-04-01 22:47:01 | FISH user=344531774518591498 fish=viidikas weight=80 value=5
2026-04-01 22:48:58 | WORK user=178852380018868224 earned=+106 lucky=True bal=26799
2026-04-01 22:49:03 | CRIME_WIN user=178852380018868224 earned=+486 bal=27285
2026-04-01 22:49:05 | BEG user=178852380018868224 earned=+76 jailed=False bal=27361
2026-04-01 22:52:25 | BEG user=401373976431165449 earned=+44 jailed=False bal=44
2026-04-01 22:52:29 | WORK user=401373976431165449 earned=+103 lucky=False bal=147
2026-04-01 22:52:39 | CRIME_FAIL user=401373976431165449 fine=-125 jailed=False bal=22
2026-04-01 22:53:01 | FISH user=401373976431165449 fish=latikas weight=1217 value=40
2026-04-01 22:57:11 | SLOTS_PAIR user=344531774518591498 bet=10000 change=5000 bal=135989

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

134
ops_admin_commands.py Normal file
View File

@@ -0,0 +1,134 @@
from __future__ import annotations
import asyncio
import datetime
import json
import logging
import os
import subprocess
import sys
from collections.abc import Awaitable, Callable
from pathlib import Path
import discord
from discord import app_commands
import strings as S
def register_ops_admin_commands(
tree: app_commands.CommandTree,
bot: discord.Client,
log: logging.Logger,
process,
start_time: datetime.datetime,
log_dir: Path,
guild_obj: discord.Object,
restart_file: Path,
get_member_cache_size: Callable[[], int],
get_paused: Callable[[], bool],
set_paused: Callable[[bool], None],
count_economy_users: Callable[[], Awaitable[int]],
) -> None:
@tree.command(name="status", description=S.CMD["status"])
@app_commands.guild_only()
@app_commands.default_permissions(manage_guild=True)
async def cmd_status(interaction: discord.Interaction):
mem = process.memory_info()
cpu = process.cpu_percent(interval=0.1)
uptime = datetime.datetime.now() - start_time
hours, rem = divmod(int(uptime.total_seconds()), 3600)
minutes, seconds = divmod(rem, 60)
tasks_count = len(asyncio.all_tasks())
latency_ms = round(bot.latency * 1000, 1)
cache_size = get_member_cache_size()
user_count = await count_economy_users()
embed = discord.Embed(title=S.STATUS_UI["title"], color=0x57F287)
embed.add_field(
name=S.STATUS_UI["uptime_field"],
value=S.STATUS_UI["uptime_val"].format(hours=hours, minutes=minutes, seconds=seconds),
inline=True,
)
embed.add_field(
name=S.STATUS_UI["latency_field"],
value=S.STATUS_UI["latency_val"].format(ms=latency_ms),
inline=True,
)
embed.add_field(
name=S.STATUS_UI["ram_field"],
value=S.STATUS_UI["ram_val"].format(mb=f"{mem.rss / 1024**2:.1f}"),
inline=True,
)
embed.add_field(
name=S.STATUS_UI["cpu_field"],
value=S.STATUS_UI["cpu_val"].format(percent=f"{cpu:.1f}"),
inline=True,
)
embed.add_field(
name=S.STATUS_UI["tasks_field"],
value=str(tasks_count),
inline=True,
)
embed.add_field(
name=S.STATUS_UI["eco_players_field"],
value=str(user_count),
inline=True,
)
embed.add_field(
name=S.STATUS_UI["members_cache_field"],
value=str(cache_size),
inline=True,
)
log_lines = [
S.STATUS_UI["log_line"].format(name=p.name, size_kb=f"{p.stat().st_size / 1024:.1f}")
for p in sorted(log_dir.glob("*.log*"))
if p.is_file()
]
embed.add_field(
name=S.STATUS_UI["log_files_field"],
value="\n".join(log_lines) or S.STATUS_UI["none"],
inline=False,
)
await interaction.response.send_message(embed=embed, ephemeral=True)
@tree.command(name="sync", description=S.CMD["sync"])
@app_commands.guild_only()
@app_commands.default_permissions(manage_guild=True)
async def cmd_sync(interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
tree.copy_global_to(guild=guild_obj)
await tree.sync(guild=guild_obj)
tree.clear_commands(guild=None)
await tree.sync()
await interaction.followup.send(S.MSG_SYNC_DONE, ephemeral=True)
log.info("/sync triggered by %s", interaction.user)
@tree.command(name="restart", description=S.CMD["restart"])
@app_commands.guild_only()
@app_commands.default_permissions(manage_guild=True)
async def cmd_restart(interaction: discord.Interaction):
restart_file.write_text(json.dumps({"channel_id": interaction.channel_id}), encoding="utf-8")
await interaction.response.send_message(S.MSG_RESTARTING, ephemeral=True)
log.info("/restart triggered by %s", interaction.user)
subprocess.Popen([sys.executable] + sys.argv, cwd=os.getcwd())
await bot.close()
@tree.command(name="shutdown", description=S.CMD["shutdown"])
@app_commands.guild_only()
@app_commands.default_permissions(manage_guild=True)
async def cmd_shutdown(interaction: discord.Interaction):
await interaction.response.send_message(S.MSG_SHUTTING_DOWN, ephemeral=True)
log.info("/shutdown triggered by %s", interaction.user)
await bot.close()
@tree.command(name="pause", description=S.CMD["pause"])
@app_commands.guild_only()
@app_commands.default_permissions(manage_guild=True)
async def cmd_pause(interaction: discord.Interaction):
paused = not get_paused()
set_paused(paused)
msg = S.MSG_PAUSED if paused else S.MSG_UNPAUSED
log.info("/pause toggled → %s by %s", "PAUSED" if paused else "UNPAUSED", interaction.user)
await interaction.response.send_message(msg, ephemeral=True)

135
ops_channel_commands.py Normal file
View File

@@ -0,0 +1,135 @@
from __future__ import annotations
import logging
from collections.abc import Callable
import discord
from discord import app_commands
import economy
import strings as S
def register_ops_channel_commands(
tree: app_commands.CommandTree,
bot: discord.Client,
log: logging.Logger,
get_allowed_channels: Callable[[], list[int]],
set_allowed_channels: Callable[[list[int]], None],
) -> None:
@tree.command(name="send", description=S.CMD["send"])
@app_commands.guild_only()
@app_commands.describe(
kanal=S.OPT["send_kanal"],
sõnum=S.OPT["send_sõnum"],
)
@app_commands.default_permissions(manage_guild=True)
async def cmd_send(interaction: discord.Interaction, kanal: discord.TextChannel, sõnum: str):
try:
await kanal.send(sõnum)
await interaction.response.send_message(
S.SEND_UI["sent"].format(channel=kanal.mention), ephemeral=True
)
except discord.Forbidden:
await interaction.response.send_message(
S.SEND_UI["forbidden"].format(channel=kanal.mention), ephemeral=True
)
except Exception as e:
await interaction.response.send_message(
S.ERR["send_failed"].format(error=e), ephemeral=True
)
@tree.command(name="economysetup", description=S.CMD["economysetup"])
@app_commands.guild_only()
@app_commands.default_permissions(manage_guild=True)
async def cmd_economysetup(interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
guild = interaction.guild
bot_member = guild.get_member(bot.user.id)
bot_top_pos = max((r.position for r in bot_member.roles if r.managed), default=1)
all_role_names = [economy.ECONOMY_ROLE] + [name for _, name in economy.LEVEL_ROLES]
created, existing = [], []
for name in all_role_names:
role = discord.utils.find(lambda r, n=name: r.name == n, guild.roles)
if role is None:
await guild.create_role(name=name, reason="/economysetup")
created.append(name)
else:
existing.append(name)
positions: dict[discord.Role, int] = {}
base = max(bot_top_pos - 1, 1)
for i, name in enumerate(all_role_names):
role = discord.utils.find(lambda r, n=name: r.name == n, guild.roles)
if role:
positions[role] = max(base - i, 1)
if positions:
try:
await guild.edit_role_positions(positions=positions)
except discord.Forbidden:
pass
embed = discord.Embed(title=S.TITLE["economysetup"], color=0x57F287)
if created:
embed.add_field(name=S.ECONOMYSETUP_UI["created_field"], value="\n".join(created), inline=True)
if existing:
embed.add_field(name=S.ECONOMYSETUP_UI["existing_field"], value="\n".join(existing), inline=True)
embed.set_footer(text=S.ECONOMYSETUP_UI["footer"])
await interaction.followup.send(embed=embed, ephemeral=True)
log.info("/economysetup triggered by %s", interaction.user)
@tree.command(name="allowchannel", description=S.CMD["allowchannel"])
@app_commands.guild_only()
@app_commands.describe(kanal=S.OPT["allowchannel_kanal"])
@app_commands.default_permissions(manage_guild=True)
async def cmd_allowchannel(interaction: discord.Interaction, kanal: discord.TextChannel):
allowed = get_allowed_channels()
if kanal.id in allowed:
await interaction.response.send_message(
S.CHANNEL_UI["already_allowed"].format(channel=kanal.mention), ephemeral=True
)
return
allowed.append(kanal.id)
set_allowed_channels(allowed)
log.info("ALLOWCHANNEL +%s by %s", kanal, interaction.user)
await interaction.response.send_message(
S.CHANNEL_UI["added"].format(channel=kanal.mention), ephemeral=True
)
@tree.command(name="denychannel", description=S.CMD["denychannel"])
@app_commands.guild_only()
@app_commands.describe(kanal=S.OPT["denychannel_kanal"])
@app_commands.default_permissions(manage_guild=True)
async def cmd_denychannel(interaction: discord.Interaction, kanal: discord.TextChannel):
allowed = get_allowed_channels()
if kanal.id not in allowed:
await interaction.response.send_message(
S.CHANNEL_UI["not_in_list"].format(channel=kanal.mention), ephemeral=True
)
return
allowed.remove(kanal.id)
set_allowed_channels(allowed)
log.info("DENYCHANNEL -%s by %s", kanal, interaction.user)
if allowed:
await interaction.response.send_message(
S.CHANNEL_UI["removed"].format(channel=kanal.mention), ephemeral=True
)
else:
await interaction.response.send_message(
S.CHANNEL_UI["removed_last"].format(channel=kanal.mention), ephemeral=True
)
@tree.command(name="channels", description=S.CMD["channels"])
@app_commands.guild_only()
@app_commands.default_permissions(manage_guild=True)
async def cmd_channels(interaction: discord.Interaction):
allowed = get_allowed_channels()
if not allowed:
desc = S.CHANNEL_UI["list_empty"]
else:
lines = "\n".join(f"• <#{cid}>" for cid in allowed)
desc = S.CHANNEL_UI["list_filled"].format(lines=lines)
embed = discord.Embed(title=S.CHANNEL_UI["list_title"], description=desc, color=0x5865F2)
await interaction.response.send_message(embed=embed, ephemeral=True)

View File

@@ -7,24 +7,26 @@ Environment variables (set in .env):
PB_URL Base URL of PocketBase (default: http://127.0.0.1:8090)
PB_ADMIN_EMAIL PocketBase admin e-mail
PB_ADMIN_PASSWORD PocketBase admin password
PB_ECONOMY_COLLECTION_DEV / PB_ECONOMY_COLLECTION_ECONOMY
"""
from __future__ import annotations
import asyncio
import logging
import os
import time
from typing import Any
import aiohttp
import config
_log = logging.getLogger("tipiCOIN.pb")
PB_URL = os.getenv("PB_URL", "http://127.0.0.1:8090")
PB_ADMIN_EMAIL = os.getenv("PB_ADMIN_EMAIL", "")
PB_ADMIN_PASSWORD = os.getenv("PB_ADMIN_PASSWORD", "")
ECONOMY_COLLECTION = "economy_users"
PB_URL = config.PB_URL
PB_ADMIN_EMAIL = config.PB_ADMIN_EMAIL
PB_ADMIN_PASSWORD = config.PB_ADMIN_PASSWORD
ECONOMY_COLLECTION = config.PB_ECONOMY_COLLECTION
_TIMEOUT = aiohttp.ClientTimeout(total=10)

View File

@@ -11,7 +11,6 @@ Requirements:
from __future__ import annotations
import asyncio
import os
import sys
from pathlib import Path
@@ -21,10 +20,12 @@ from dotenv import load_dotenv
sys.path.insert(0, str(Path(__file__).parent.parent))
load_dotenv()
PB_URL = os.getenv("PB_URL", "http://127.0.0.1:8090")
PB_ADMIN_EMAIL = os.getenv("PB_ADMIN_EMAIL", "")
PB_ADMIN_PASSWORD = os.getenv("PB_ADMIN_PASSWORD", "")
COLLECTION = "economy_users"
import config # noqa: E402
PB_URL = config.PB_URL
PB_ADMIN_EMAIL = config.PB_ADMIN_EMAIL
PB_ADMIN_PASSWORD = config.PB_ADMIN_PASSWORD
COLLECTION = config.PB_ECONOMY_COLLECTION
# ---------------------------------------------------------------------------
# New fields to add

View File

@@ -0,0 +1,180 @@
"""Destructively recreate economy PocketBase collections for dev + economy profiles.
Usage:
python scripts/reset_pb_collections.py --confirm
This will DELETE and recreate the collections configured by:
- PB_ECONOMY_COLLECTION_DEV
- PB_ECONOMY_COLLECTION_ECONOMY
"""
from __future__ import annotations
import argparse
import asyncio
import sys
from pathlib import Path
import aiohttp
from dotenv import load_dotenv
sys.path.insert(0, str(Path(__file__).parent.parent))
load_dotenv()
import config # noqa: E402
PB_URL = config.PB_URL
PB_ADMIN_EMAIL = config.PB_ADMIN_EMAIL
PB_ADMIN_PASSWORD = config.PB_ADMIN_PASSWORD
def _text_field(name: str, required: bool = False) -> dict:
return {
"name": name,
"type": "text",
"required": required,
"options": {"min": None, "max": None, "pattern": ""},
}
def _number_field(name: str) -> dict:
return {
"name": name,
"type": "number",
"required": False,
"options": {"min": None, "max": None, "noDecimal": False},
}
def _bool_field(name: str) -> dict:
return {"name": name, "type": "bool", "required": False}
def _json_field(name: str) -> dict:
return {"name": name, "type": "json", "required": False}
def _collection_payload(name: str) -> dict:
fields = [
_text_field("user_id", required=True),
_number_field("balance"),
_number_field("exp"),
_number_field("daily_streak"),
_text_field("last_daily"),
_text_field("last_work"),
_text_field("last_beg"),
_text_field("last_crime"),
_text_field("last_rob"),
_text_field("last_heist"),
_text_field("last_streak_date"),
_text_field("jailed_until"),
_text_field("last_fish"),
_json_field("items"),
_json_field("item_uses"),
_json_field("reminders"),
_json_field("prestige_upgrades"),
_json_field("fish_book"),
_json_field("fish_inventory"),
_bool_field("eco_banned"),
_bool_field("jailbreak_used"),
_number_field("heist_global_cd_until"),
_number_field("peak_balance"),
_number_field("lifetime_earned"),
_number_field("lifetime_lost"),
_number_field("work_count"),
_number_field("beg_count"),
_number_field("total_wagered"),
_number_field("biggest_win"),
_number_field("biggest_loss"),
_number_field("slots_jackpots"),
_number_field("crimes_attempted"),
_number_field("crimes_succeeded"),
_number_field("times_jailed"),
_number_field("total_bail_paid"),
_number_field("heists_joined"),
_number_field("heists_won"),
_number_field("total_given"),
_number_field("total_received"),
_number_field("best_daily_streak"),
_number_field("prestige_level"),
_number_field("prestige_points"),
_number_field("season_total_exp"),
_number_field("total_fish_caught"),
]
return {
"name": name,
"type": "base",
"fields": fields,
"listRule": None,
"viewRule": None,
"createRule": None,
"updateRule": None,
"deleteRule": None,
}
async def _auth_token(session: aiohttp.ClientSession) -> str:
async with session.post(
f"{PB_URL}/api/collections/_superusers/auth-with-password",
json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD},
) as resp:
if resp.status != 200:
raise RuntimeError(f"Auth failed ({resp.status}): {await resp.text()}")
return (await resp.json())["token"]
async def _delete_if_exists(session: aiohttp.ClientSession, headers: dict[str, str], name: str) -> None:
async with session.get(f"{PB_URL}/api/collections/{name}", headers=headers) as resp:
if resp.status == 404:
print(f"[SKIP] {name} does not exist")
return
if resp.status != 200:
raise RuntimeError(f"Could not fetch {name} ({resp.status}): {await resp.text()}")
async with session.delete(f"{PB_URL}/api/collections/{name}", headers=headers) as resp:
if resp.status not in (200, 204):
raise RuntimeError(f"Delete failed for {name} ({resp.status}): {await resp.text()}")
print(f"[DELETE] {name}")
async def _create_collection(session: aiohttp.ClientSession, headers: dict[str, str], name: str) -> None:
payload = _collection_payload(name)
async with session.post(f"{PB_URL}/api/collections", json=payload, headers=headers) as resp:
if resp.status not in (200, 201):
raise RuntimeError(f"Create failed for {name} ({resp.status}): {await resp.text()}")
print(f"[CREATE] {name}")
async def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--confirm", action="store_true", help="Required flag to run destructive reset")
args = parser.parse_args()
if not args.confirm:
raise SystemExit("Refusing to run without --confirm (this operation deletes collections).")
targets = []
for name in [config.PB_ECONOMY_COLLECTION_DEV, config.PB_ECONOMY_COLLECTION_ECONOMY]:
if name and name not in targets:
targets.append(name)
if not targets:
raise SystemExit("No target collections configured.")
timeout = aiohttp.ClientTimeout(total=20)
async with aiohttp.ClientSession(timeout=timeout) as session:
token = await _auth_token(session)
headers = {"Authorization": token}
for name in targets:
await _delete_if_exists(session, headers, name)
await _create_collection(session, headers, name)
print("\nDone. Collections recreated:")
for name in targets:
print(f" - {name}")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -858,6 +858,11 @@ BIRTHDAY_UI: dict[str, str] = {
"footer": "Leht {month}/12 · {month_name}",
}
BIRTHDAY_MONTHS: list[str] = [
"Jaanuar", "Veebruar", "Märts", "Aprill", "Mai", "Juuni",
"Juuli", "August", "September", "Oktoober", "November", "Detsember",
]
# ---------------------------------------------------------------------------
# /check summary strings
# ---------------------------------------------------------------------------
@@ -870,6 +875,7 @@ CHECK_UI: dict[str, str] = {
"stat_uid": "Kasutaja ID",
"stat_discord": "Discordi kasutajanimi",
"stat_bday": "Sünnipäev",
"no_name": "(no name)",
"done": "**Kontroll lõpetatud!**",
"already_ok": "✅ Juba korras: {count}",
"fixed": "🔧 Parandatud: {count}",
@@ -878,9 +884,35 @@ CHECK_UI: dict[str, str] = {
"errors": "⚠️ Vead: {count}",
"details_header": "**Üksikasjad:**",
"details_more": "... ja {count} rohkem",
"detail_error": "⚠️ {error}",
"detail_nickname": "hüüdnimi",
"detail_roles_added": "+rollid: {roles}",
"detail_changed": "🔧 **{name}**: {parts}",
"ids_filled": "\n🔑 Täideti **{count}** puuduvat kasutaja ID-d.",
}
# ---------------------------------------------------------------------------
# /status UI
# ---------------------------------------------------------------------------
STATUS_UI: dict[str, str] = {
"title": "🖥️ Boti olek",
"uptime_field": "🕐 Uptime",
"uptime_val": "{hours}t {minutes}m {seconds}s",
"latency_field": "📡 Latency",
"latency_val": "{ms} ms",
"ram_field": "🧠 RAM (RSS)",
"ram_val": "{mb} MB",
"cpu_field": "⚙️ CPU",
"cpu_val": "{percent}%",
"tasks_field": "🔄 Async tasks",
"eco_players_field": "👤 Eco players",
"members_cache_field": "📋 Liikmed (cache)",
"log_files_field": "📂 Log files",
"log_line": "`{name}` - {size_kb} KB",
"none": "-",
}
# ---------------------------------------------------------------------------
# Admin command responses and DMs
# ---------------------------------------------------------------------------
@@ -1089,6 +1121,7 @@ JAILBREAK_UI: dict[str, str] = {
LEADERBOARD_UI: dict[str, str] = {
"house_entry": "🤖 {name} *(maja)* - {balance}",
"house_default_name": "TipiBOT",
"no_entries": "Keegi ei ole veel punkte teeninud.",
"footer": "Lehekülg {page}/{total} · {count} mängijat",
"btn_coins": "🪙 Mündid",