3 Commits

Author SHA1 Message Date
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
21 changed files with 5176 additions and 4087 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,
)

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",