rewrite: Rewrite economy module.

This commit is contained in:
2022-11-30 16:59:31 +02:00
parent 01f967b0e6
commit 73bf63cf70
5 changed files with 982 additions and 2 deletions

View File

@@ -51,7 +51,7 @@ async def main():
shardname=shardname,
db=db,
config=conf,
initial_extensions=['core', 'analytics', 'babel', 'modules'],
initial_extensions=['utils', 'core', 'analytics', 'babel', 'modules'],
web_client=session,
app_ipc=shard_talk,
testing_guilds=conf.bot.getintlist('admin_guilds'),

View File

@@ -3,7 +3,8 @@ this_package = 'modules'
active = [
'.sysadmin',
'.test',
'.reminders'
'.reminders',
'.economy',
]

View File

@@ -0,0 +1,5 @@
from .cog import Economy
async def setup(bot):
await bot.add_cog(Economy(bot))

905
bot/modules/economy/cog.py Normal file
View File

@@ -0,0 +1,905 @@
from typing import Optional, Union
from enum import Enum
import discord
from discord.ext import commands as cmds
from discord import app_commands as appcmds
from data import Registry, RowModel, RegisterEnum, ORDER
from data.columns import Integer, Bool, String, Column, Timestamp
from meta import LionCog, LionBot, LionContext
from meta.errors import ResponseTimedOut
from babel import LocalBabel
from utils.ui import LeoUI, LeoModal, Confirm, Pager
from utils.lib import error_embed, MessageArgs, utc_now
babel = LocalBabel('economy')
_, _p, _np = babel._, babel._p, babel._np
MAX_COINS = 2**16
class TransactionType(Enum):
"""
Schema
------
CREATE TYPE CoinTransactionType AS ENUM(
'REFUND',
'TRANSFER',
'SHOP_PURCHASE',
'STUDY_SESSION',
'ADMIN',
'TASKS'
);
"""
REFUND = 'REFUND',
TRANSFER = 'TRANSFER',
PURCHASE = 'SHOP_PURCHASE',
SESSION = 'STUDY_SESSION',
ADMIN = 'ADMIN',
TASKS = 'TASKS',
class AdminActionTarget(Enum):
"""
Schema
------
CREATE TYPE EconAdminTarget AS ENUM(
'ROLE',
'USER',
'GUILD'
);
"""
ROLE = 'ROLE',
USER = 'USER',
GUILD = 'GUILD',
class AdminActionType(Enum):
"""
Schema
------
CREATE TYPE EconAdminAction AS ENUM(
'SET',
'ADD'
);
"""
SET = 'SET',
ADD = 'ADD',
class EconomyData(Registry, name='economy'):
_TransactionType = RegisterEnum(TransactionType, 'CoinTransactionType')
_AdminActionTarget = RegisterEnum(AdminActionTarget, 'EconAdminTarget')
_AdminActionType = RegisterEnum(AdminActionType, 'EconAdminAction')
class Transaction(RowModel):
"""
Schema
------
CREATE TABLE coin_transactions(
transactionid SERIAL PRIMARY KEY,
transactiontype CoinTransactionType NOT NULL,
guildid BIGINT NOT NULL REFERENCES guild_config (guildid) ON DELETE CASCADE,
actorid BIGINT NOT NULL,
amount INTEGER NOT NULL,
bonus INTEGER NOT NULL,
from_account BIGINT,
to_account BIGINT,
refunds INTEGER REFERENCES coin_transactions (transactionid) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT (now() at time zone 'utc')
);
CREATE INDEX coin_transaction_guilds ON coin_transactions (guildid);
"""
_tablename_ = 'coin_transactions'
transactionid = Integer(primary=True)
transactiontype: Column[TransactionType] = Column()
guildid = Integer()
actorid = Integer()
amount = Integer()
bonus = Integer()
from_account = Integer()
to_account = Integer()
refunds = Integer()
created_at = Timestamp()
@classmethod
async def execute_transaction(
transaction_type: TransactionType,
guildid: int, actorid: int,
from_account: int, to_account: int, amount: int,
description: str,
note: Optional[str] = None, reference: Optional[str] = None, reminding: Optional[int] = None
):
...
class ShopTransaction(RowModel):
"""
Schema
------
CREATE TABLE coin_transactions_shop(
transactionid INTEGER PRIMARY KEY REFERENCES coin_transactions (transactionid) ON DELETE CASCADE,
itemid INTEGER NOT NULL REFERENCES shop_items (itemid) ON DELETE CASCADE
);
"""
_tablename_ = 'coin_transactions_shop'
transactionid = Integer(primary=True)
itemid = Integer()
class TaskTransaction(RowModel):
"""
Schema
------
CREATE TABLE coin_transactions_tasks(
transactionid INTEGER PRIMARY KEY REFERENCES coin_transactions (transactionid) ON DELETE CASCADE,
count INTEGER NOT NULL
);
"""
_tablename_ = 'coin_transactions_tasks'
transactionid = Integer(primary=True)
count = Integer()
class SessionTransaction(RowModel):
"""
Schema
------
CREATE TABLE coin_transactions_sessions(
transactionid INTEGER PRIMARY KEY REFERENCES coin_transactions (transactionid) ON DELETE CASCADE,
sessionid INTEGER NOT NULL REFERENCES session_history (sessionid) ON DELETE CASCADE
);
"""
_tablename_ = 'coin_transactions_sessions'
transactionid = Integer(primary=True)
sessionid = Integer()
class AdminActions(RowModel):
"""
Schema
------
CREATE TABLE economy_admin_actions(
actionid SERIAL PRIMARY KEY,
target_type EconAdminTarget NOT NULL,
action_type EconAdminAction NOT NULL,
targetid INTEGER NOT NULL,
amount INTEGER NOT NULL
);
"""
_tablename_ = 'economy_admin_actions'
actionid = Integer(primary=True)
target_type: Column[AdminActionTarget] = Column()
action_type: Column[AdminActionType] = Column()
targetid = Integer()
amount = Integer()
class AdminTransactions(RowModel):
"""
Schema
------
CREATE TABLE coin_transactions_admin_actions(
actionid INTEGER NOT NULL REFERENCES economy_admin_actions (actionid),
transactionid INTEGER NOT NULL REFERENCES coin_transactions (transactionid),
PRIMARY KEY (actionid, transactionid)
);
CREATE INDEX coin_transactions_admin_actions_transactionid ON coin_transactions_admin_actions (transactionid);
"""
_tablename_ = 'coin_transactions_admin_actions'
actionid = Integer(primary=True)
transactionid = Integer(primary=True)
class Economy(LionCog):
"""
Commands
--------
/economy balances [target:<mentionable>] [add:<int>] [set:<int>].
With no arguments, show a summary of current balances in the server.
With a target user or role, show their balance, and possibly their most recent transactions.
With a target user or role, and add or set, modify their balance. Confirm if more than 1 user is affected.
With no target user or role, apply to everyone in the guild. Confirm if more than 1 user affected.
/economy reset [target:<mentionable>]
Reset the economy system for the given target, or everyone in the guild.
Acts as an alias to `/economy balances target:target set:0
/economy history [target:<mentionable>]
Display a paged audit trail with the history of the selected member,
all the users in the selected role, or all users.
/sendcoins <user:<user>> [note:<str>]
Send coins to the specified user, with an optional note.
"""
def __init__(self, bot: LionBot):
self.bot = bot
self.data = bot.db.load_registry(EconomyData())
async def cog_load(self):
await self.data.init()
# ----- Economy group commands -----
@cmds.hybrid_group(name=_p('cmd:economy', "economy"))
@cmds.guild_only()
async def economy_group(self, ctx: LionContext):
pass
@economy_group.command(
name=_p('cmd:economy_balance', "balance"),
description=_p(
'cmd:economy_balance|desc',
"Display and modify LionCoin balance for members or roles."
)
)
@appcmds.rename(
target=_p('cmd:economy_balance|param:target', "target"),
add=_p('cmd:economy_balance|param:add', "add"),
set_to=_p('cmd:economy_balance|param:set', "set")
)
@appcmds.describe(
target=_p(
'cmd:economy_balance|param:target|desc',
"Target user or role to view or update. Use @everyone to update the entire guild."
),
add=_p(
'cmd:economy_balance|param:add|desc',
"Number of LionCoins to add to the target member's balance. May be negative to remove."
),
set_to=_p(
'cmd:economy_balance|param:set|set',
"New balance to set the target's balance to."
)
)
async def economy_balance_cmd(
self,
ctx: LionContext,
target: discord.User | discord.Member | discord.Role,
set_to: Optional[appcmds.Range[int, 0, MAX_COINS]] = None,
add: Optional[int] = None
):
t = self.bot.translator.t
cemoji = self.bot.config.emojis.getemoji('coin')
targets: list[Union[discord.User, discord.Member]]
if not ctx.guild:
# Added for the typechecker
# This is impossible from the guild_only ward
return
if not self.bot.core:
return
if not ctx.interaction:
return
if isinstance(target, discord.Role):
targets = [mem for mem in target.members if not mem.bot]
role = target
else:
targets = [target]
role = None
if role and not targets:
# Guard against provided target role having no members
# Possible chunking failed for this guild, want to explicitly inform.
await ctx.reply(
embed=error_embed(
t(_p(
'cmd:economy_balance|error:no_target',
"There are no valid members in {role.mention}! It has a total of `0` LC."
)).format(role=target)
),
ephemeral=True
)
elif not role and target.bot:
# Guard against reading or modifying a bot account
await ctx.reply(
embed=error_embed(
t(_p(
'cmd:economy_balance|error:target_is_bot',
"Bots cannot have coin balances!"
))
),
ephemeral=True
)
elif set_to is not None and add is not None:
# Requested operation doesn't make sense
await ctx.reply(
embed=error_embed(
t(_p(
'cmd:economy_balance|error:args',
"You cannot simultaneously `set` and `add` member balances!"
))
),
ephemeral=True
)
elif set_to is not None or add is not None:
# Setting route
# First ensure all the targets we will be updating already have rows
# As this is one of the few operations that acts on members not already registered,
# We may need to do a mass row create operation.
targetids = set(target.id for target in targets)
if len(targets) > 1:
conn = await ctx.bot.db.get_connection()
async with conn.transaction():
# First fetch the members which currently exist
query = self.bot.core.data.Member.table.select_where(guildid=ctx.guild.id)
query.select('userid').with_no_adapter()
if 2 * len(targets) < len(ctx.guild.members):
# More efficient to fetch the targets explicitly
query.where(userid=list(targetids))
existent_rows = await query
existentids = set(r['userid'] for r in existent_rows)
# Then check if any new userids need adding, and if so create them
new_ids = targetids.difference(existentids)
if new_ids:
# We use ON CONFLICT IGNORE here in case the users already exist.
await self.bot.core.data.User.table.insert_many(
('userid',),
*((id,) for id in new_ids)
).on_conflict(ignore=True)
# TODO: Replace 0 here with the starting_coin value
await self.bot.core.data.Member.table.insert_many(
('guildid', 'userid', 'coins'),
*((ctx.guild.id, id, 0) for id in new_ids)
).on_conflict(ignore=True)
else:
# With only one target, we can take a simpler path, and make better use of local caches.
await self.bot.core.lions.fetch(ctx.guild.id, target.id)
# Now we are certain these members have a database row
# Perform the appropriate action
if role:
affected = t(_np(
'cmd:economy_balance|embed:success|affected',
"One user was affected.",
"**{count}** users were affected.",
len(targets)
)).format(count=len(targets))
conf_affected = t(_np(
'cmd:economy_balance|confirm|affected',
"One user will be affected.",
"**{count}** users will be affected.",
len(targets)
)).format(count=len(targets))
confirm = Confirm(conf_affected)
confirm.confirm_button = t(_p(
'cmd:economy_balance|confirm|button:confirm',
"Yes, adjust balances"
))
confirm.confirm_button = t(_p(
'cmd:economy_balance|confirm|button:cancel',
"No, cancel"
))
if set_to is not None:
if role:
if role.is_default():
description = t(_p(
'cmd:economy_balance|embed:success_set|desc',
"All members of **{guild_name}** have had their "
"balance set to {coin_emoji}**{amount}**."
)).format(
guild_name=ctx.guild.name,
coin_emoji=cemoji,
amount=set_to
) + '\n' + affected
conf_description = t(_p(
'cmd:economy_balance|confirm_set|desc',
"Are you sure you want to set everyone's balance to {coin_emoji}**{amount}**?"
)).format(
coin_emoji=cemoji,
amount=set_to
) + '\n' + conf_affected
else:
description = t(_p(
'cmd:economy_balance|embed:success_set|desc',
"All members of {role_mention} have had their "
"balance set to {coin_emoji}**{amount}**."
)).format(
role_mention=role.mention,
coin_emoji=cemoji,
amount=set_to
) + '\n' + affected
conf_description = t(_p(
'cmd:economy_balance|confirm_set|desc',
"Are you sure you want to set the balance of everyone with {role_mention} "
"to {coin_emoji}**{amount}**?"
)).format(
role_mention=role.mention,
coin_emoji=cemoji,
amount=set_to
) + '\n' + conf_affected
confirm.embed.description = conf_description
try:
result = await confirm.ask(ctx.interaction, ephemeral=True)
except ResponseTimedOut:
return
if not result:
return
else:
description = t(_p(
'cmd:economy_balance|embed:success_set|desc',
"{user_mention} now has a balance of {coin_emoji}**{amount}**."
)).format(
user_mention=target.mention,
coin_emoji=cemoji,
amount=set_to
)
await self.bot.core.data.Member.table.update_where(
guildid=ctx.guild.id, userid=list(targetids)
).set(
coins=set_to
)
else:
if role:
if role.is_default():
description = t(_p(
'cmd:economy_balance|embed:success_add|desc',
"All members of **{guild_name}** have been given "
"{coin_emoji}**{amount}**."
)).format(
guild_name=ctx.guild.name,
coin_emoji=cemoji,
amount=add
) + '\n' + affected
conf_description = t(_p(
'cmd:economy_balance|confirm_add|desc',
"Are you sure you want to add **{amount}** to everyone's balance?"
)).format(
coin_emoji=cemoji,
amount=add
) + '\n' + conf_affected
else:
description = t(_p(
'cmd:economy_balance|embed:success_add|desc',
"All members of {role_mention} have been given "
"{coin_emoji}**{amount}**."
)).format(
role_mention=role.mention,
coin_emoji=cemoji,
amount=add
) + '\n' + affected
conf_description = t(_p(
'cmd:economy_balance|confirm_add|desc',
"Are you sure you want to add {coin_emoji}**{amount}** to everyone in {role_mention}?"
)).format(
coin_emoji=cemoji,
amount=add,
role_mention=role.mention
) + '\n' + conf_affected
confirm.embed.description = conf_description
try:
result = await confirm.ask(ctx.interaction, ephemeral=True)
except ResponseTimedOut:
return
if not result:
return
results = await self.bot.core.data.Member.table.update_where(
guildid=ctx.guild.id, userid=list(targetids)
).set(
coins=(self.bot.core.data.Member.coins + add)
)
# Single member case occurs afterwards so we can pick up the results
if not role:
description = t(_p(
'cmd:economy_balance|embed:success_add|desc',
"{user_mention} was given {coin_emoji}**{amount}**, and "
"now has a balance of {coin_emoji}**{new_amount}**."
)).format(
user_mention=target.mention,
coin_emoji=cemoji,
amount=add,
new_amount=results[0]['coins']
)
title = t(_np(
'cmd:economy_balance|embed:success|title',
"Account successfully updated.",
"Accounts successfully updated.",
len(targets)
))
await ctx.reply(
embed=discord.Embed(
colour=discord.Colour.brand_green(),
description=description,
title=title,
)
)
else:
# Viewing route
MemModel = self.bot.core.data.Member
if role:
query = MemModel.fetch_where(
(MemModel.guildid == role.guild.id) & (MemModel.coins != 0)
)
query.order_by('coins', ORDER.DESC)
if not role.is_default():
# Everyone role is handled differently for data efficiency
ids = [target.id for target in targets]
query = query.where(userid=ids)
rows = await query
name = t(_p(
'cmd:economy_balance|embed:role_lb|author',
"Balance sheet for {name}"
)).format(name=role.name if not role.is_default() else role.guild.name)
if rows:
if role.is_default():
header = t(_p(
'cmd:economy_balance|embed:role_lb|header',
"This server has a total balance of {coin_emoji}**{total}**."
)).format(
coin_emoji=cemoji,
total=sum(row.coins for row in rows)
)
else:
header = t(_p(
'cmd:economy_balance|embed:role_lb|header',
"{role_mention} has `{count}` members with non-zero balance, "
"with a total balance of {coin_emoji}**{total}**."
)).format(
count=len(targets),
role_mention=role.mention,
total=sum(row.coins for row in rows),
coin_emoji=cemoji
)
# Build the leaderboard:
lb_format = t(_p(
'cmd:economy_balance|embed:role_lb|row_format',
"`[{pos:>{numwidth}}]` | `{coins:>{coinwidth}} LC` | {mention}"
))
blocklen = 20
blocks = [rows[i:i+blocklen] for i in range(0, len(rows), blocklen)]
paged = len(blocks) > 1
pages = []
for i, block in enumerate(blocks):
lines = []
numwidth = len(str(i + len(block)))
coinwidth = len(str(max(row.coins for row in rows)))
for j, row in enumerate(block, start=i):
lines.append(
lb_format.format(
pos=j, numwidth=numwidth,
coins=row.coins, coinwidth=coinwidth,
mention=f"<@{row.userid}>"
)
)
lb_block = '\n'.join(lines)
embed = discord.Embed(
description=f"{header}\n{lb_block}"
)
embed.set_author(name=name)
if paged:
embed.set_footer(
text=t(_p(
'cmd:economy_balance|embed:role_lb|footer',
"Page {page}/{total}"
)).format(page=i+1, total=len(blocks))
)
pages.append(MessageArgs(embed=embed))
pager = Pager(pages, show_cancel=True)
await pager.run(ctx.interaction)
else:
if role.is_default():
header = t(_p(
'cmd:economy_balance|embed:role_lb|header',
"This server has a total balance of {coin_emoji}**0**."
)).format(
coin_emoji=cemoji,
)
else:
header = t(_p(
'cmd:economy_balance|embed:role_lb|header',
"The role {role_mention} has a total balance of {coin_emoji}**0**."
)).format(
role_mention=role.mention,
coin_emoji=cemoji
)
embed = discord.Embed(
colour=discord.Colour.orange(),
description=header
)
embed.set_author(name=name)
await ctx.reply(embed=embed)
else:
# If we have a single target, show their current balance, with a short transaction history.
user = targets[0]
row = await self.bot.core.data.Member.fetch(ctx.guild.id, user.id)
embed = discord.Embed(
colour=discord.Colour.orange(),
description=t(_p(
'cmd:economy_balance|embed:single|desc',
"{mention} currently owns {coin_emoji} {coins}."
)).format(
mention=user.mention,
coin_emoji=self.bot.config.emojis.getemoji('coin'),
coins=row.coins
)
).set_author(
icon_url=user.avatar,
name=t(_p(
'cmd:economy_balance|embed:single|author',
"Balance statement for {user}"
)).format(user=str(user))
)
await ctx.reply(
embed=embed
)
# TODO: Add small transaction history block when we have transaction formatter
@economy_group.command(
name=_p('cmd:economy_reset', "reset"),
description=_p(
'cmd:economy_reset|desc',
"Reset the coin balance for a target user or role. (See also \"economy balance\".)"
)
)
@appcmds.rename(
target=_p('cmd:economy_reset|param:target', "target"),
)
@appcmds.describe(
target=_p(
'cmd:economy_reset|param:target|desc',
"Target user or role to view or update. Use @everyone to reset the entire guild."
),
)
async def economy_reset_cmd(
self,
ctx: LionContext,
target: discord.User | discord.Member | discord.Role,
):
# TODO: Permission check
t = self.bot.translator.t
starting_balance = 0
coin_emoji = self.bot.config.emojis.getemoji('coin')
# Typechecker guards
if not ctx.guild:
return
if not ctx.bot.core:
return
if not ctx.interaction:
return
if isinstance(target, discord.Role):
if target.is_default():
# Confirm: Reset Guild
confirm_msg = t(_p(
'cmd:economy_reset|confirm:reset_guild|desc',
"Are you sure you want to reset the coin balance for everyone in **{guild_name}**?\n"
"*This is not reversible!*"
)).format(
guild_name=ctx.guild.name
)
confirm = Confirm(confirm_msg)
confirm.confirm_button.label = t(_p(
'cmd:economy_reset|confirm:reset_guild|button:confirm',
"Yes, reset the economy"
))
confirm.cancel_button.label = t(_p(
'cmd:economy_reset|confirm:reset_guild|button:cancel',
"Cancel reset"
))
try:
result = await confirm.ask(ctx.interaction, ephemeral=True)
except ResponseTimedOut:
return
if result:
# Complete reset
await ctx.bot.core.data.Member.table.update_where(
guildid=ctx.guild.id,
).set(coins=starting_balance)
await ctx.reply(
embed=discord.Embed(
description=t(_p(
'cmd:economy_reset|embed:success_guild|desc',
"Everyone in **{guild_name}** has had their balance reset to {coin_emoji}**{amount}**."
)).format(
guild_name=ctx.guild.name,
coin_emoji=coin_emoji,
amount=starting_balance
)
)
)
else:
# Provided a role to reset
targets = [member for member in target.members if not member.bot]
if not targets:
# Error: No targets
await ctx.reply(
embed=error_embed(
t(_p(
'cmd:economy_reset|error:no_target|desc',
"The role {mention} has no members to reset!"
)).format(mention=target.mention)
),
ephemeral=True
)
else:
# Confirm: Reset Role
# Include number of people affected
confirm_msg = t(_p(
'cmd:economy_reset|confirm:reset_role|desc',
"Are you sure you want to reset the balance for everyone in {mention}?\n"
"**{count}** members will be affected."
)).format(
mention=target.mention,
count=len(targets)
)
confirm = Confirm(confirm_msg)
confirm.confirm_button.label = t(_p(
'cmd:economy_reset|confirm:reset_role|button:confirm',
"Yes, complete economy reset"
))
confirm.cancel_button.label = t(_p(
'cmd:economy_reset|confirm:reset_role|button:cancel',
"Cancel"
))
try:
result = await confirm.ask(ctx.interaction, ephemeral=True)
except ResponseTimedOut:
return
if result:
# Complete reset
await ctx.bot.core.data.Member.table.update_where(
guildid=ctx.guild.id,
userid=[t.id for t in targets],
).set(coins=starting_balance)
await ctx.reply(
embed=discord.Embed(
description=t(_p(
'cmd:economy_reset|embed:success_role|desc',
"Everyone in {role_mention} has had their "
"coin balance reset to {coin_emoji}**{amount}**."
)).format(
mention=target.mention,
coin_emoji=coin_emoji,
amount=starting_balance
)
)
)
else:
# Provided an individual user.
# Reset their balance
# Do not create the member row if it does not already exist.
# TODO: Audit logging trail
await ctx.bot.core.data.Member.table.update_where(
guuildid=ctx.guild.id,
userid=target.id,
).set(coins=starting_balance)
await ctx.reply(
embed=discord.Embed(
description=t(_p(
'cmd:economy_reset|embed:success_user|desc',
"{mention}'s balance has been reset to {coin_emoji}**{amount}**."
)).format(
mention=target.mention,
coin_emoji=coin_emoji,
amount=starting_balance
)
)
)
@cmds.hybrid_command(
name=_p('cmd:send', "send"),
description=_p(
'cmd:send|desc',
"Gift the target user a certain number of LionCoins."
)
)
@appcmds.rename(
target=_p('cmd:send|param:target', "target"),
amount=_p('cmd:send|param:amount', "amount"),
note=_p('cmd:send|param:note', "note")
)
@appcmds.describe(
target=_p('cmd:send|param:target|desc', "User to send the gift to"),
amount=_p('cmd:send|param:amount|desc', "Number of coins to send"),
note=_p('cmd:send|param:note|desc', "Optional note to add to the gift.")
)
@appcmds.guild_only()
async def send_cmd(self, ctx: LionContext,
target: discord.User | discord.Member,
amount: appcmds.Range[int, 1, MAX_COINS],
note: Optional[str] = None):
"""
Send `amount` lioncoins to the provided `target`, with the optional `note` attached.
"""
if not ctx.interaction:
return
if not ctx.guild:
return
if not self.bot.core:
return
t = self.bot.translator.t
Member = self.bot.core.data.Member
target_lion = await self.bot.core.lions.fetch(ctx.guild.id, target.id)
# TODO: Add a "Send thanks" button to the DM?
# Alternative flow could be waiting until the target user presses accept
await ctx.interaction.response.defer(thinking=True, ephemeral=True)
conn = await self.bot.db.get_connection()
async with conn.transaction():
# We do this in a transaction so that if something goes wrong,
# the coins deduction is rolled back atomicly
balance = ctx.alion.data.coins
if amount > balance:
await ctx.interaction.edit_original_response(
embed=error_embed(
t(_p(
'cmd:send|error:insufficient',
"You do not have enough lioncoins to do this!\n"
"`Current Balance:` {coin_emoji}{balance}"
)).format(
coin_emoji=self.bot.config.emojis.getemoji('coin'),
balance=balance
)
),
)
return
# Transfer the coins
await ctx.alion.data.update(coins=(Member.coins - amount))
await target_lion.data.update(coins=(Member.coins + amount))
# TODO: Audit trail
# Message target
embed = discord.Embed(
title=t(_p(
'cmd:send|embed:gift|title',
"{user} sent you a gift!"
)).format(user=ctx.author.name),
description=t(_p(
'cmd:send|embed:gift|desc',
"{mention} sent you {coin_emoji}**{amount}**."
)).format(
coin_emoji=self.bot.config.emojis.getemoji('coin'),
amount=amount,
mention=ctx.author.mention
),
timestamp=utc_now()
)
if note:
embed.add_field(
name="Note Attached",
value=note
)
try:
await target.send(embed=embed)
failed = False
except discord.HTTPException:
failed = True
pass
# Ack transfer
embed = discord.Embed(
colour=discord.Colour.brand_green(),
description=t(_p(
'cmd:send|embed:ack|desc',
"**{coin_emoji}{amount}** has been deducted from your balance and sent to {mention}!"
)).format(
coin_emoji=self.bot.config.emojis.getemoji('coin'),
amount=amount,
mention=target.mention
)
)
if failed:
embed.description = t(_p(
'cmd:send|embed:ack|desc|error:unreachable',
"Unfortunately, I was not able to message the recipient. Perhaps they have me blocked?"
))
await ctx.interaction.edit_original_response(embed=embed)

View File

@@ -139,6 +139,75 @@ ALTER TABLE reminders
ADD CONSTRAINT fk_reminders_users FOREIGN KEY (userid) REFERENCES user_config (userid) ON DELETE CASCADE NOT VALID;
-- }}}
-- Economy data {{{
CREATE TYPE CoinTransactionType AS ENUM(
'REFUND',
'TRANSFER',
'SHOP_PURCHASE',
'STUDY_SESSION',
'ADMIN',
'TASKS'
);
CREATE TABLE coin_transactions(
transactionid SERIAL PRIMARY KEY,
transactiontype CoinTransactionType NOT NULL,
guildid BIGINT NOT NULL REFERENCES guild_config (guildid) ON DELETE CASCADE,
actorid BIGINT NOT NULL,
amount INTEGER NOT NULL,
bonus INTEGER NOT NULL,
from_account BIGINT,
to_account BIGINT,
refunds INTEGER REFERENCES coin_transactions (transactionid) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT (now() at time zone 'utc')
);
CREATE INDEX coin_transaction_guilds ON coin_transactions (guildid);
CREATE TABLE coin_transactions_shop(
transactionid INTEGER PRIMARY KEY REFERENCES coin_transactions (transactionid) ON DELETE CASCADE,
itemid INTEGER NOT NULL REFERENCES shop_items (itemid) ON DELETE CASCADE
);
CREATE TABLE coin_transactions_tasks(
transactionid INTEGER PRIMARY KEY REFERENCES coin_transactions (transactionid) ON DELETE CASCADE,
count INTEGER NOT NULL
);
CREATE TABLE coin_transactions_sessions(
transactionid INTEGER PRIMARY KEY REFERENCES coin_transactions (transactionid) ON DELETE CASCADE,
sessionid INTEGER NOT NULL REFERENCES session_history (sessionid) ON DELETE CASCADE
);
CREATE TYPE EconAdminTarget AS ENUM(
'ROLE',
'USER',
'GUILD'
);
CREATE TYPE EconAdminAction AS ENUM(
'SET',
'ADD'
);
CREATE TABLE economy_admin_actions(
actionid SERIAL PRIMARY KEY,
target_type EconAdminTarget NOT NULL,
action_type EconAdminAction NOT NULL,
targetid INTEGER NOT NULL,
amount INTEGER NOT NULL
);
CREATE TABLE coin_transactions_admin_actions(
actionid INTEGER NOT NULL REFERENCES economy_admin_actions (actionid),
transactionid INTEGER NOT NULL REFERENCES coin_transactions (transactionid),
PRIMARY KEY (actionid, transactionid)
);
CREATE INDEX coin_transactions_admin_actions_transactionid ON coin_transactions_admin_actions (transactionid);
-- }}}
INSERT INTO VersionHistory (version, author) VALUES (13, 'v12-v13 migration');
-- vim: set fdm=marker: