from __future__ import annotations from collections.abc import Callable import discord from discord import app_commands from core import economy import strings as S from ._replies import reply_db_error 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 if res["reason"] == "db_error": await reply_db_error(interaction) return 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"] == "db_error": await reply_db_error(interaction) return 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"] == "db_error": await reply_db_error(interaction) return 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, )