From c41374bbaace5e894da6bd9028a94cf5c6ecd90a Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 26 Oct 2023 09:50:07 +0300 Subject: [PATCH] feat(premium): Implement gem API and admin. --- config/example-bot.conf | 2 +- src/modules/__init__.py | 1 + src/modules/premium/__init__.py | 10 + src/modules/premium/cog.py | 705 +++++++++++++++++++++++++ src/modules/premium/data.py | 92 ++++ src/modules/premium/errors.py | 19 + src/modules/premium/ui/premium.py | 0 src/modules/premium/ui/transactions.py | 198 +++++++ 8 files changed, 1026 insertions(+), 1 deletion(-) create mode 100644 src/modules/premium/__init__.py create mode 100644 src/modules/premium/cog.py create mode 100644 src/modules/premium/data.py create mode 100644 src/modules/premium/errors.py create mode 100644 src/modules/premium/ui/premium.py create mode 100644 src/modules/premium/ui/transactions.py diff --git a/config/example-bot.conf b/config/example-bot.conf index ec7bb97b..feb33705 100644 --- a/config/example-bot.conf +++ b/config/example-bot.conf @@ -17,7 +17,7 @@ invite_bot = [ENDPOINTS] guild_log = -gem_transaction = +gem_log = [LOGGING] log_file = bot.log diff --git a/src/modules/__init__.py b/src/modules/__init__.py index d23dcd7c..74964ff5 100644 --- a/src/modules/__init__.py +++ b/src/modules/__init__.py @@ -20,6 +20,7 @@ active = [ '.meta', '.sponsors', '.topgg', + '.premium', '.test', ] diff --git a/src/modules/premium/__init__.py b/src/modules/premium/__init__.py new file mode 100644 index 00000000..222b76bb --- /dev/null +++ b/src/modules/premium/__init__.py @@ -0,0 +1,10 @@ +import logging +from babel.translator import LocalBabel + +babel = LocalBabel('premium') +logger = logging.getLogger(__name__) + + +async def setup(bot): + from .cog import PremiumCog + await bot.add_cog(PremiumCog(bot)) diff --git a/src/modules/premium/cog.py b/src/modules/premium/cog.py new file mode 100644 index 00000000..e88a8611 --- /dev/null +++ b/src/modules/premium/cog.py @@ -0,0 +1,705 @@ +from typing import Optional +import asyncio + +import discord +from discord.ext import commands as cmds +import discord.app_commands as appcmds + +from discord.ui.button import Button, ButtonStyle +from discord.ui.text_input import TextInput, TextStyle + +from meta import LionCog, LionBot, LionContext +from meta.errors import SafeCancellation, UserInputError +from meta.logger import log_wrap +from utils.lib import utc_now +from utils.ui import FastModal +from wards import sys_admin_ward +from constants import MAX_COINS + +from . import logger, babel +from .data import PremiumData, GemTransactionType +from .ui.transactions import TransactionList +from .errors import GemTransactionFailed, BalanceTooLow, BalanceTooHigh + +_p = babel._p + + +class PremiumCog(LionCog): + buy_gems_link = "https://lionbot.org/donate" + + def __init__(self, bot: LionBot): + self.bot = bot + self.data: PremiumData = bot.db.load_registry(PremiumData()) + + self.gem_logger: Optional[discord.Webhook] = None + + async def cog_load(self): + await self.data.init() + + if (leo_setting_cog := self.bot.get_cog('LeoSettings')) is not None: + self.crossload_group(self.leo_group, leo_setting_cog.leo_group) + + if (gem_log_url := self.bot.config.endpoints.get('gem_log', None)) is not None: + self.gem_logger = discord.Webhook.from_url(gem_log_url, session=self.bot.web_client) + + + # ----- API ----- + def buy_gems_buttons(self) -> Button: + t = self.bot.translator.t + + button = Button( + style=ButtonStyle.link, + label=t(_p( + 'button:gems|label', + "Buy Gems" + )), + emoji=self.bot.config.emojis.gem, + url=self.buy_gems_link, + ) + return button + + async def get_gem_balance(self, userid: int) -> int: + """ + Get the up-to-date gem balance for this user. + + Creates the User row if it does not already exist. + """ + record = await self.bot.core.data.User.fetch(userid, cached=False) + if record is None: + record = await self.bot.core.data.User.create(userid=userid) + return record.gems + + async def get_gift_count(self, userid: int) -> int: + """ + Compute the number of gifts this user has sent, by counting Transaction rows. + """ + record = await self.data.GemTransaction.table.select_where( + from_account=userid, + transaction_type=GemTransactionType.GIFT, + ).select( + gift_count='COUNT(*)' + ).with_no_adapter() + + return record[0]['gift_count'] or 0 + + async def is_premium_guild(self, guildid: int) -> bool: + """ + Check whether the given guild currently has premium status. + """ + row = await self.data.PremiumGuild.fetch(guildid) + now = utc_now() + + premium = (row is not None) and (row.premium_until > now) + return premium + + @log_wrap(isolate=True) + async def _add_gems(self, userid: int, amount: int): + """ + Transaction helper method to atomically add `amount` gems to account `userid`, + creating the account if required. + + Do not use this method for a gem transaction. Use `gem_transaction` instead. + """ + async with self.bot.db.connection() as conn: + self.bot.db.conn = conn + async with conn.transaction(): + model = self.bot.core.data.User + rows = await model.table.update_where(userid=userid).set(gems=model.gems + amount) + if not rows: + # User does not exist, create it + if amount < 0: + raise BalanceTooLow + if amount > MAX_COINS: + raise BalanceTooHigh + row = (await model.create(userid=userid, gems=amount)).data + else: + row = rows[0] + + if row['gems'] < 0: + raise BalanceTooLow + + async def gem_transaction( + self, + transaction_type: GemTransactionType, + *, + actorid: int, + from_account: Optional[int], to_account: Optional[int], + amount: int, description: str, + note: Optional[str] = None, reference: Optional[str] = None, + ) -> PremiumData.GemTransaction: + """ + Perform a gem transaction with the given parameters. + + This atomically creates a row in the 'gem_transactions' table, + updates the account balances, + and posts in the gem audit log. + + Parameters + ---------- + transaction_type: GemTransactionType + The type of transaction. + actorid: int + The userid of the actor who initiated this transaction. + Automatic actions (e.g. webhook triggered) may have their own unique id. + from_account: Optional[int] + The userid of the source account. + May be `None` if there is no source account (e.g. manual modification by admin). + to_account: Optional[int] + The userid of the destination account. + May be `None` if there is no destination account. + amount: int + The number of LionGems to transfer. + description: str + An informative description of the transaction for auditing purposes. + Should include the pathway (e.g. command) through which the transaction was executed. + note: Optional[str] + Optional user-readable note added by the actor. + Usually attached in a notification visible by the target. + (E.g. thanks message from system/admin, or note attached to gift.) + reference: str + Optional admin-readable transaction reference. + This may be the message link of a command message, + or an external id/reference for an automatic transaction. + + Raises + ------ + BalanceTooLow: + Raised if either source or target account would go below 0. + """ + async with self.bot.db.connection() as conn: + self.bot.db.conn = conn + async with conn.transaction(): + if from_account is not None: + await self._add_gems(from_account, -amount) + if to_account is not None: + await self._add_gems(to_account, amount) + + row = await self.data.GemTransaction.create( + transaction_type=transaction_type, + actorid=actorid, + from_account=from_account, + to_account=to_account, + amount=amount, + description=description, + note=note, + reference=reference, + ) + logger.info( + f"LionGem Transaction performed. Transaction data: {row!r}" + ) + await self.audit_log(row) + return row + + async def audit_log(self, row: PremiumData.GemTransaction): + """ + Log the provided gem transaction to the global gem audit log. + + If this fails, or the audit log does not exist, logs a warning. + """ + posted = False + if self.gem_logger is not None: + embed = discord.Embed( + colour=discord.Colour.orange(), + title=f"Gem Transaction #{row.transactionid}", + timestamp=row._timestamp, + ) + embed.add_field(name="Type", value=row.transaction_type.name) + embed.add_field(name="Amount", value=str(row.amount)) + embed.add_field(name="Actor", value=f"<@{row.actorid}>") + embed.add_field(name="From Account", value=f"<@{row.from_account}>" if row.from_account else 'None') + embed.add_field(name="To Account", value=f"<@{row.to_account}>" if row.to_account else 'None') + embed.add_field(name='Description', value=str(row.description), inline=False) + if row.note: + embed.add_field(name='Note', value=str(row.note), inline=False) + if row.reference: + embed.add_field(name='Reference', value=str(row.reference), inline=False) + + try: + await self.gem_logger.send(embed=embed) + except discord.HTTPException: + pass + + if not posted: + logger.warning( + f"Missed gem audit logging for gem transaction: {row!r}" + ) + + # ----- User Commands ----- + @cmds.hybrid_command( + name=_p('cmd:free', "free"), + description=_p( + 'cmd:free|desc', + "Get free LionGems!" + ) + ) + async def cmd_free(self, ctx: LionContext): + t = self.bot.translator.t + content = t(_p( + 'cmd:free|embed|description', + "You can get free LionGems by sharing our project on your Discord server and social media!\n" + "If you have well-established, or YouTube, Instagram, and TikTok accounts," + " we will reward you for creating videos and content about the bot.\n" + "If you have a big server, you can promote our project and get LionGems in return.\n" + "For more details, contact `arihoresh` or open a Ticket in the [support server](https://discord.gg/studylions)." + )) + thumb = "https://cdn.discordapp.com/attachments/890619584158265405/972791204498530364/Untitled_design_44.png" + title = t(_p( + 'cmd:free|embed|title', + "Get FREE LionGems!" + )) + embed = discord.Embed( + title=title, + description=content, + colour=0x41f097 + ) + embed.set_thumbnail(url=thumb) + + await ctx.reply(embed=embed) + + @cmds.hybrid_command( + name=_p('cmd:gift', "gift"), + description=_p( + 'cmd:gift|desc', + "Gift your LionGems to another user!" + ) + ) + @appcmds.rename( + user=_p('cmd:gift|param:user', "user"), + amount=_p('cmd:gift|param:amount', "amount"), + note=_p('cmd:gift|param:note', "note"), + ) + @appcmds.describe( + user=_p( + 'cmd:gift|param:user|desc', + "User to which you want to gift your LionGems." + ), + amount=_p( + 'cmd:gift|param:amount|desc', + "Number of LionGems to gift." + ), + note=_p( + 'cmd:gift|param:note|desc', + "Optional note to attach to your gift." + ), + ) + async def cmd_gift(self, ctx: LionContext, + user: discord.User, + amount: appcmds.Range[int, 1, MAX_COINS], + note: Optional[appcmds.Range[str, 1, 1024]] = None): + if not ctx.interaction: + return + t = self.bot.translator.t + + # Validate target + if user.bot: + raise UserInputError( + t(_p( + 'cmd:gift|error:target_bot', + "You cannot gift LionGems to bots!" + )) + ) + + if user.id == ctx.author.id: + raise UserInputError( + t(_p( + 'cmd:gift|error:target_is_author', + "You cannot gift LionGems to yourself!" + )) + ) + + # Prepare and open gift confirmation modal + amount_field = TextInput( + label=t(_p( + 'cmd:gift|modal:confirm|field:amount|label', + "Number of LionGems to Gift" + )), + default=str(amount), + required=True, + ) + note_field = TextInput( + label=t(_p( + 'cmd:gift|modal:confirm|field:note|label', + "Add an optional note to your gift" + )), + default=note or '', + required=False, + max_length=1024, + style=TextStyle.long, + ) + modal = FastModal( + amount_field, note_field, + title=t(_p( + 'cmd:gift|modal:confirm|title', + "Confirm LionGem Gift" + )) + ) + + await ctx.interaction.response.send_modal(modal) + + try: + interaction = await modal.wait_for(timeout=300) + except asyncio.TimeoutError: + # Presume user cancelled and wants to abort + raise SafeCancellation + + await interaction.response.defer(thinking=False) + + # Parse amount + amountstr = amount_field.value + if not amountstr.isdigit(): + raise UserInputError( + t(_p( + 'cmd:gift|error:parse_amount', + "Could not parse `{provided}` as a number!" + )).format(provided=amountstr) + ) + amount = int(amountstr) + + if amount == 0: + raise UserInputError( + t(_p( + 'cmd:gift|error:amount_zero', + "Cannot gift `0` gems." + )) + ) + + # Get author's balance, make sure they have enough + author_balance = await self.get_gem_balance(ctx.author.id) + if author_balance < amount: + raise UserInputError( + t(_p( + 'cmd:gift|error:author_balance_too_low', + "Insufficient balance to send {gem}**{amount}**!\n" + "Current balance: {gem}**{balance}**" + )).format( + gem=self.bot.config.emojis.gem, + amount=amount, + balance=author_balance, + ) + ) + + # Everything seems to be in order, run the transaction + try: + transaction = await self.gem_transaction( + GemTransactionType.GIFT, + actorid=ctx.author.id, + from_account=ctx.author.id, to_account=user.id, + amount=amount, + description="Gift given through command '/gift'", + note=note_field.value or None + ) + except BalanceTooLow: + raise UserInputError( + t(_p( + 'cmd:gift|error:balance_too_low', + "Insufficient Balance to complete gift!" + )) + ) + + # Attempt to send note to user + + thumb = "https://cdn.discordapp.com/attachments/925799205954543636/938704034578194443/C85AF926-9F75-466F-9D8E-D47721427F5D.png" + icon = "https://cdn.discordapp.com/attachments/925799205954543636/938703943683416074/4CF1C849-D532-4DEC-B4C9-0AB11F443BAB.png" + desc = t(_p( + 'cmd:gift|target_msg|desc', + "You were just gifted {gem}**{amount}** by {user}!\n" + "To use them, use the command {skin_cmd} to change your graphics skin!" + )).format( + gem=self.bot.config.emojis.gem, + amount=amount, + user=ctx.author.mention, + skin_cmd=self.bot.core.mention_cmd('my skin'), + ) + embed = discord.Embed( + description=desc, + colour=discord.Colour.orange() + ) + embed.set_thumbnail(url=thumb) + embed.set_author( + name=t(_p('cmd:gift|target_msg|author:name', "LionGems Delivery!")), + icon_url=icon, + ) + embed.set_footer( + text=t(_p( + 'cmd:gift|target_msg|footer:text', + "You now have {balance} LionGems" + )).format( + balance=await self.get_gem_balance(user.id), + ) + ) + embed.timestamp = utc_now() + + note = note_field.value + if note: + embed.add_field( + name=t(_p( + 'cmd:gift|target_msg|field:note|name', + "The sender attached a note" + )), + value=note + ) + + notify_sent = False + try: + await user.send(embed=embed) + notify_sent = True + except discord.HTTPException: + logger.info( + f"Could not send LionGem gift target their gift notification. Transaction {transaction.transactionid}" + ) + + # Finally, send the ack back to the author + embed = discord.Embed( + colour=discord.Colour.brand_green(), + title=t(_p( + 'cmd:gift|embed:success|title', + "Gift Sent!" + )), + description=t(_p( + 'cmd:gift|embed:success|description', + "Your gift of {gem}**{amount}** is on its way to {target}!" + )).format( + gem=self.bot.config.emojis.gem, + amount=amount, + target=user.mention, + ) + ) + embed.set_footer( + text=t(_p( + 'cmd:gift|embed:success|footer', + "New Balance: {balance} LionGems", + )).format(balance=await self.get_gem_balance(ctx.author.id)) + ) + if not notify_sent: + embed.add_field( + name="", + value=t(_p( + 'cmd:gift|embed:success|field:notify_failed|value', + "Unfortunately, I couldn't tell them about it! " + "They might have direct messages with me turned off." + )) + ) + + await ctx.reply(embed=embed, ephemeral=True) + + + @cmds.hybrid_command( + name=_p('cmd:premium', "premium"), + description=_p( + 'cmd:premium|desc', + "Upgrade your server with LionGems!" + ) + ) + async def cmd_premium(self, ctx: LionContext): + # TODO + ... + + # ----- Owner Commands ----- + @LionCog.placeholder_group + @cmds.hybrid_group("leo", with_app_command=False) + async def leo_group(self, ctx: LionContext): + ... + + + @leo_group.command( + name=_p('cmd:leo_gems', "gems"), + description=_p( + 'cmd:leo_gems|desc', + "View and adjust a user's LionGem balance." + ) + ) + @appcmds.rename( + target=_p('cmd:leo_gems|param:target', "target"), + adjustment=_p('cmd:leo_gems|param:adjustment', "adjustment"), + note=_p('cmd:leo_gems|param:note', "note"), + reason=_p('cmd:leo_gems|param:reason', "reason") + ) + @appcmds.describe( + target=_p( + 'cmd:leo_gems|param:target|desc', + "Target user you wish to view or modify LionGems for." + ), + adjustment=_p( + 'cmd:leo_gems|param:adjustment|desc', + "Number of LionGems to add to the target's balance (may be negative to remove)" + ), + note=_p( + 'cmd:leo_gems|param:note|desc', + "Optional note to attach to the delivery message when adding LionGems." + ), + reason=_p( + 'cmd:leo_gems|param:reason|desc', + 'Optional reason or context to add to the gem audit log for this transaction.' + ) + ) + @sys_admin_ward + async def cmd_leo_gems(self, ctx: LionContext, + target: discord.User, + adjustment: Optional[int] = None, + note: Optional[appcmds.Range[str, 0, 1024]] = None, + reason: Optional[appcmds.Range[str, 0, 1024]] = None,): + if not ctx.interaction: + return + t = self.bot.translator.t + + if adjustment is None or adjustment == 0: + # History viewing pathway + ui = TransactionList(self.bot, target.id, callerid=ctx.author.id) + await ui.run(ctx.interaction) + await ui.wait() + else: + # Adjustment path + # Show confirmation modal with note and reason + adjustment_field = TextInput( + label=t(_p( + 'cmd:leo_gems|adjust|modal:confirm|field:amount|label', + "Number of LionGems to add. May be negative." + )), + default=str(adjustment), + required=True, + ) + note_field = TextInput( + label=t(_p( + 'cmd:leo_gems|adjust|modal:confirm|field:note|label', + "Optional note to attach to delivery message." + )), + default=note, + style=TextStyle.long, + max_length=1024, + required=False, + ) + reason_field = TextInput( + label=t(_p( + 'cmd:leo_gems|adjust|modal:confirm|field:reason|label', + "Optional reason to add to the audit log." + )), + default=reason, + style=TextStyle.long, + max_length=1024, + required=False, + ) + + modal = FastModal( + adjustment_field, note_field, reason_field, + title=t(_p( + 'cmd:leo_gems|adjust|modal:confirm|title', + "Confirm LionGem Adjustment" + )) + ) + await ctx.interaction.response.send_modal(modal) + + try: + interaction = await modal.wait_for(timeout=300) + except asyncio.TimeoutError: + raise SafeCancellation + + await interaction.response.defer(thinking=False) + + # Parse values + try: + amount = int(adjustment_field.value) + except ValueError: + raise UserInputError( + t(_p( + 'cmd:leo_gems|adjust|error:parse_adjustment', + "Could not parse `{given}` as an integer." + )).format(given=adjustment_field.value) + ) + note = note_field.value or None + reason = reason_field.value or None + + # Run transaction + try: + transaction = await self.gem_transaction( + GemTransactionType.ADMIN, + actorid=ctx.author.id, + from_account=None, to_account=target.id, + amount=amount, + description=f"Admin balance adjustment with '/leo gems'.\n{reason}", + note=note + ) + except GemTransactionFailed: + raise UserInputError( + t(_p( + 'cmd:leo_gems|adjust|error:unknown', + "Balance adjustment failed! Check logs for more information." + )) + ) + # DM user with note if applicable + if amount > 0: + thumb = "https://cdn.discordapp.com/attachments/925799205954543636/938704034578194443/C85AF926-9F75-466F-9D8E-D47721427F5D.png" + icon = "https://cdn.discordapp.com/attachments/925799205954543636/938703943683416074/4CF1C849-D532-4DEC-B4C9-0AB11F443BAB.png" + desc = t(_p( + 'cmd:leo_gems|adjust|target_msg|desc', + "You were given {gem}**{amount}**!\n" + "To use them, use the command {skin_cmd} to change your graphics skin!" + )).format( + gem=self.bot.config.emojis.gem, + amount=amount, + skin_cmd=self.bot.core.mention_cmd('my skin'), + ) + embed = discord.Embed( + description=desc, + colour=discord.Colour.orange() + ) + embed.set_thumbnail(url=thumb) + embed.set_author( + name=t(_p('cmd:leo_gems|adjust|target_msg|author:name', "LionGems Delivery!")), + icon_url=icon, + ) + embed.set_footer( + text=t(_p( + 'cmd:leo_gems|adjust|target_msg|footer:text', + "You now have {balance} LionGems" + )).format( + balance=await self.get_gem_balance(target.id), + ) + ) + embed.timestamp = utc_now() + + note = note_field.value + if note: + embed.add_field( + name=t(_p( + 'cmd:lion_gems|adjust|target_msg|field:note|name', + "Note" + )), + value=note + ) + + try: + await target.send(embed=embed) + target_notified = True + except discord.HTTPException: + target_notified = False + else: + target_notified = None + + # Ack the operation + embed = discord.Embed( + colour=discord.Colour.brand_green(), + title=t(_p( + 'cmd:lion_gems|adjust|embed:success|title', + "Success" + )), + description=t(_p( + 'cmd:lion_gems|adjust|embed:success|description', + "Added {gem}**{amount}** to {target}'s account.\n" + "They now have {gem}**{balance}**" + )).format( + gem=self.bot.config.emojis.gem, + target=target.mention, + amount=amount, + balance=await self.get_gem_balance(target.id), + ) + ) + if target_notified is False: + embed.add_field( + name="", + value=t(_p( + 'cmd:lion_gems|adjust|embed:success|field:notify_failed|value', + "Could not notify the target, they probably have direct messages disabled." + )) + ) + + await ctx.reply(embed=embed, ephemeral=True) diff --git a/src/modules/premium/data.py b/src/modules/premium/data.py new file mode 100644 index 00000000..0643bee0 --- /dev/null +++ b/src/modules/premium/data.py @@ -0,0 +1,92 @@ +from enum import Enum + +from psycopg import sql +from meta.logger import log_wrap +from data import Registry, RowModel, RegisterEnum, Table +from data.columns import Integer, Bool, Column, Timestamp, String + + +class GemTransactionType(Enum): + """ + Schema + ------ + CREATE TYPE GemTransactionType AS ENUM ( + 'ADMIN', + 'GIFT', + 'PURCHASE', + 'AUTOMATIC' + ); + """ + ADMIN = 'ADMIN', + GIFT = 'GIFT', + PURCHASE = 'PURCHASE', + AUTOMATIC = 'AUTOMATIC', + + +class PremiumData(Registry): + GemTransactionType = RegisterEnum(GemTransactionType, 'GemTransactionType') + + class GemTransaction(RowModel): + """ + Schema + ------ + + CREATE TABLE gem_transactions( + transactionid SERIAL PRIMARY KEY, + transaction_type GemTransactionType NOT NULL, + actorid BIGINT NOT NULL, + from_account BIGINT, + to_account BIGINT, + amount INTEGER NOT NULL, + description TEXT NOT NULL, + note TEXT, + reference TEXT, + _timestamp TIMESTAMPTZ DEFAULT now() + ); + CREATE INDEX gem_transactions_from ON gem_transactions (from_account); + """ + _tablename_ = 'gem_transactions' + + transactionid = Integer(primary=True) + transaction_type: Column[GemTransactionType] = Column() + actorid = Integer() + from_account = Integer() + to_account = Integer() + amount = Integer() + description = String() + note = String() + reference = String() + + _timestamp = Timestamp() + + class PremiumGuild(RowModel): + """ + Schema + ------ + CREATE TABLE premium_guilds( + guildid BIGINT PRIMARY KEY REFERENCES guild_config, + premium_since TIMESTAMPTZ NOT NULL DEFAULT now(), + premium_until TIMESTAMPTZ NOT NULL DEFAULT now(), + custom_skin_id INTEGER REFERENCES customised_skins + ); + """ + _tablename_ = "premium_guilds" + _cache_ = {} + + guildid = Integer(primary=True) + premium_since = Timestamp() + premium_until = Timestamp() + custom_skin_id = Integer() + + """ + CREATE TABLE premium_guild_contributions( + contributionid SERIAL PRIMARY KEY, + userid BIGINT NOT NULL REFERENCES user_config, + guildid BIGINT NOT NULL REFERENCES premium_guilds, + transactionid INTEGER REFERENCES gem_transactions, + duration INTEGER NOT NULL, + _timestamp TIMESTAMPTZ DEFAULT now() + ); + """ + premium_guild_contributions = Table('premium_guild_contributions') + diff --git a/src/modules/premium/errors.py b/src/modules/premium/errors.py new file mode 100644 index 00000000..043817de --- /dev/null +++ b/src/modules/premium/errors.py @@ -0,0 +1,19 @@ +class GemTransactionFailed(Exception): + """ + Base exception class used when a gem transaction failed. + """ + pass + + +class BalanceTooLow(GemTransactionFailed): + """ + Exception raised when transaction results in a negative gem balance. + """ + pass + + +class BalanceTooHigh(GemTransactionFailed): + """ + Exception raised when transaction results in gem balance overflow. + """ + pass diff --git a/src/modules/premium/ui/premium.py b/src/modules/premium/ui/premium.py new file mode 100644 index 00000000..e69de29b diff --git a/src/modules/premium/ui/transactions.py b/src/modules/premium/ui/transactions.py new file mode 100644 index 00000000..012a9784 --- /dev/null +++ b/src/modules/premium/ui/transactions.py @@ -0,0 +1,198 @@ +from typing import Optional +import asyncio +import datetime as dt + +import discord +from discord.ui.button import button, Button, ButtonStyle + +from meta import LionBot, conf +from data import ORDER + +from utils.ui import MessageUI, input +from utils.lib import MessageArgs, tabulate + +from .. import babel, logger +from ..data import PremiumData + +_p = babel._p + + +class TransactionList(MessageUI): + block_len = 5 + + def __init__(self, bot: LionBot, userid: int, **kwargs): + super().__init__(**kwargs) + + self.bot = bot + self.userid = userid + + self._pagen = 0 + self.blocks: list[list[PremiumData.GemTransaction]] = [[]] + + @property + def page_count(self): + return len(self.blocks) + + @property + def pagen(self): + self._pagen = self._pagen % self.page_count + return self._pagen + + @pagen.setter + def pagen(self, value): + self._pagen = value % self.page_count + + @property + def current_page(self): + return self.blocks[self.pagen] + + # ----- UI Components ----- + + # Backwards + @button(emoji=conf.emojis.backward, style=ButtonStyle.grey) + async def prev_button(self, press: discord.Interaction, pressed: Button): + await press.response.defer(thinking=True, ephemeral=True) + self.pagen -= 1 + await self.refresh(thinking=press) + + # Jump to page + @button(label="JUMP_PLACEHOLDER", style=ButtonStyle.blurple) + async def jump_button(self, press: discord.Interaction, pressed: Button): + """ + Jump-to-page button. + Loads a page-switch dialogue. + """ + t = self.bot.translator.t + try: + interaction, value = await input( + press, + title=t(_p( + 'ui:transactions|button:jump|input:title', + "Jump to page" + )), + question=t(_p( + 'ui:transactions|button:jump|input:question', + "Page number to jump to" + )) + ) + value = value.strip() + except asyncio.TimeoutError: + return + + if not value.lstrip('- ').isdigit(): + error_embed = discord.Embed( + title=t(_p( + 'ui:transactions|button:jump|error:invalid_page', + "Invalid page number, please try again!" + )), + colour=discord.Colour.brand_red() + ) + await interaction.response.send_message(embed=error_embed, ephemeral=True) + else: + await interaction.response.defer(thinking=True) + pagen = int(value.lstrip('- ')) + if value.startswith('-'): + pagen = -1 * pagen + elif pagen > 0: + pagen = pagen - 1 + self.pagen = pagen + await self.refresh(thinking=interaction) + + async def jump_button_refresh(self): + component = self.jump_button + component.label = f"{self.pagen + 1}/{self.page_count}" + component.disabled = (self.page_count <= 1) + + # Forward + @button(emoji=conf.emojis.forward, style=ButtonStyle.grey) + async def next_button(self, press: discord.Interaction, pressed: Button): + await press.response.defer(thinking=True) + self.pagen += 1 + await self.refresh(thinking=press) + + # Quit + @button(emoji=conf.emojis.cancel, style=ButtonStyle.red) + async def quit_button(self, press: discord.Interaction, pressed: Button): + """ + Quit the UI. + """ + await press.response.defer() + await self.quit() + + # ----- UI Flow ----- + async def make_message(self) -> MessageArgs: + t = self.bot.translator.t + + title = t(_p( + 'ui:transactions|embed|title', + "Gem Transactions for user `{userid}`" + )).format(userid=self.userid) + + rows = self.current_page + if rows: + embed = discord.Embed( + colour=discord.Colour.orange(), + title=title, + description=t(_p( + 'ui:transactions|embed|desc:balance', + "User {target} has a LionGem balance of {gem}**{balance}**" + )).format( + gem=self.bot.config.emojis.gem, + target=f"<@{self.userid}>", + balance=await (self.bot.get_cog('PremiumCog')).get_gem_balance(self.userid), + ) + ) + for row in rows: + name = f"Transaction #{row.transactionid}" + table_rows = ( + ('timestamp', discord.utils.format_dt(row._timestamp)), + ('type', row.transaction_type.name), + ('amount', str(row.amount)), + ('actor', f"<@{row.actorid}>"), + ('from', f"`{row.from_account}`" if row.from_account else 'None'), + ('to', f"`{row.to_account}`" if row.to_account else 'None'), + ('reference', str(row.reference)), + ) + table = '\n'.join(tabulate(*table_rows)) + embed.add_field( + name=name, + value=f"{row.description}\n{table}", + inline=False + ) + else: + embed = discord.Embed( + colour=discord.Colour.brand_red(), + description = t(_p( + 'ui:transactions|embed|desc:no_transactions', + "This user has no related gem transactions!" + )) + ) + return MessageArgs(embed=embed) + + async def refresh_layout(self): + to_refresh = ( + self.jump_button_refresh(), + ) + await asyncio.gather(*to_refresh) + + if self.page_count > 1: + self.set_layout( + (self.prev_button, self.jump_button, self.quit_button, self.next_button), + ) + else: + self.set_layout( + (self.quit_button,) + ) + + async def reload(self): + model = PremiumData.GemTransaction + + rows = await model.fetch_where( + (model.from_account == self.userid) | (model.to_account == self.userid) + ).order_by('_timestamp', ORDER.DESC) + + blocks = [ + rows[i:i+self.block_len] + for i in range(0, len(rows), self.block_len) + ] + self.blocks = blocks or [[]]