Files
tipibot/commands/economy_support_commands.py
Rene Arumetsa 037628d24f feat(economy): add vanity shop and harden economy money-safety
Vanity shop (/vanity): cosmetic badges/titles as a pure whale coin sink -
purchases burn coins (not credited to the house) and equip a badge shown on
/profile. New vanity_owned/vanity_active fields, schema sync, and tests.

Money-safety and robustness fixes from a codebase review:

- _parse_amount now rejects negative amounts. Every bet/give/request flows
  through it, so a negative value can no longer mint coins on a loss/transfer
  path that trusts the caller's sign (all call sites already guarded <= 0;
  this closes the source).
- do_blackjack_payout no longer raises on a DB failure. The stake was already
  deducted in do_blackjack_bet, so it now logs critical with the owed amount
  (for admin reconciliation) and returns db_error; all payout call sites render
  a clear "payout failed" notice instead of crashing the interaction.
- Instant "kohv" consumable now cancels the pending reminder DMs for the
  cooldowns it wipes (via new INSTANT_RESET_COMMANDS), so no stale/duplicate
  reminders fire.
- Renamed the misleadingly-named _refund_user_safe -> _debit_house_safe (it
  debits the house) and dropped its ignored first arg.
- Added __all__ to vanity.py and consumables.py so `import *` no longer leaks
  incidental imports into the economy namespace.
- Documented Kõrvaklapid's +25 coin daily bonus in README and DEV_NOTES.

Tests: blackjack payout DB-failure safety and INSTANT_RESET_COMMANDS lockstep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013VbAVsrZuYesea99mPMmPT
2026-09-04 02:09:25 +03:00

374 lines
15 KiB
Python

from __future__ import annotations
from collections.abc import Callable
import discord
from discord import app_commands
from core 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
# Reserve the amount synchronously (no await between the range check
# and the decrement) so two concurrent modal submits can't both fund
# the same request. discord.py runs each submit as its own task and
# do_give is not idempotent, so without this reservation a funder
# could double-submit and transfer `amount` more than once.
self._view.remaining -= amount
res = await economy.do_give(interaction.user.id, self._view.requester.id, amount)
if not res["ok"]:
self._view.remaining += amount # roll back the reservation
data = await economy.get_user(interaction.user.id)
await interaction.response.send_message(
S.ERR["broke"].format(bal=coin(data["balance"])), ephemeral=True
)
return
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 self.remaining <= 0 or self.is_finished():
await interaction.response.send_message(S.ERR["request_closed"], ephemeral=True)
return
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()
# -- /consumables -------------------------------------------------------
def _consumables_embed(user_data: dict) -> discord.Embed:
embed = discord.Embed(
title=S.CONSUMABLES_UI["title"],
description=S.CONSUMABLES_UI["desc"].format(bal=coin(user_data["balance"])),
color=0xF4C430,
)
active = economy.active_buffs(user_data)
if active:
lines = [
S.CONSUMABLES_UI["buff_line"].format(
name=S.CONSUMABLES_UI[f"kind_{kind}"],
time=economy.format_td(economy.buff_remaining(user_data, kind)),
)
for kind in active
]
buff_value = "\n".join(lines)
else:
buff_value = S.CONSUMABLES_UI["active_none"]
embed.add_field(name=S.CONSUMABLES_UI["active_header"], value=buff_value, inline=False)
for cons in economy.CONSUMABLES.values():
embed.add_field(
name=f"{cons['emoji']} {cons['name']} · {cons['cost']} {economy.COIN}",
value=cons["description"],
inline=False,
)
return embed
@tree.command(name="consumables", description=S.CMD["consumables"])
@app_commands.describe(ese=S.OPT["consumable_ese"])
@app_commands.choices(
ese=[
app_commands.Choice(name=f"{c['name']} ({c['cost']} TipiCOINi)", value=cid)
for cid, c in economy.CONSUMABLES.items()
]
)
async def cmd_consumables(
interaction: discord.Interaction,
ese: app_commands.Choice[str] | None = None,
):
if ese is None:
data = await economy.get_user(interaction.user.id)
await interaction.response.send_message(
embed=_consumables_embed(data), ephemeral=True
)
return
res = await economy.do_buy_consumable(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"] == "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
cons = res["consumable"]
if res["instant"]:
# Kohv wiped these cooldowns, so any reminder DM already scheduled for
# them is now stale - cancel it (the next command run reschedules).
for cmd in economy.INSTANT_RESET_COMMANDS:
cancel_reminder_task(interaction.user.id, cmd)
desc = S.CONSUMABLES_UI["bought_instant"].format(balance=coin(res["balance"]))
else:
key = "bought_extended" if res["extended"] else "bought_buff"
desc = S.CONSUMABLES_UI[key].format(
time=economy.format_td(res["remaining"]),
balance=coin(res["balance"]),
)
embed = discord.Embed(
title=S.CONSUMABLES_UI["bought_title"].format(emoji=cons["emoji"], name=cons["name"]),
description=desc,
color=0x57F287,
)
await interaction.response.send_message(embed=embed)
# -- /vanity ------------------------------------------------------------
def _vanity_embed(user_data: dict) -> discord.Embed:
owned = set(user_data.get("vanity_owned") or [])
active = user_data.get("vanity_active")
embed = discord.Embed(
title=S.VANITY_UI["title"],
description=S.VANITY_UI["desc"].format(bal=coin(user_data.get("balance", 0))),
color=0xF4C430,
)
for vid, v in economy.VANITY.items():
if vid == active:
status = S.VANITY_UI["line_active"]
elif vid in owned:
status = S.VANITY_UI["line_owned"]
else:
status = f"{v['cost']} {economy.COIN}"
embed.add_field(
name=f"{v['emoji']} {v['name']} · {status}",
value=S.VANITY_UI["entry_title"].format(title=v["title"]),
inline=False,
)
embed.set_footer(text=S.VANITY_UI["footer"])
return embed
@tree.command(name="vanity", description=S.CMD["vanity"])
@app_commands.describe(ese=S.OPT["vanity_ese"])
@app_commands.choices(
ese=[
app_commands.Choice(name=f"{v['emoji']} {v['title']} ({v['cost']} TipiCOINi)", value=vid)
for vid, v in economy.VANITY.items()
]
+ [app_commands.Choice(name=S.VANITY_UI["none_choice"], value=economy.vanity.NONE_ID)]
)
async def cmd_vanity(
interaction: discord.Interaction,
ese: app_commands.Choice[str] | None = None,
):
if ese is None:
data = await economy.get_user(interaction.user.id)
await interaction.response.send_message(
embed=_vanity_embed(data), ephemeral=True
)
return
res = await economy.do_vanity_select(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"] == "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
action = res["action"]
if action == "unequipped":
await interaction.response.send_message(S.VANITY_UI["unequipped"], ephemeral=True)
return
v = res["vanity"]
if action == "bought":
msg = S.VANITY_UI["bought"].format(
emoji=v["emoji"], title=v["title"], balance=coin(res["balance"])
)
else:
msg = S.VANITY_UI["equipped"].format(emoji=v["emoji"], title=v["title"])
await interaction.response.send_message(msg)
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,
)