Merge pull request #31 from StudyLions/staging
Interactions and Premium
This commit is contained in:
@@ -1,2 +1,2 @@
|
|||||||
CONFIG_FILE = "config/bot.conf"
|
CONFIG_FILE = "config/bot.conf"
|
||||||
DATA_VERSION = 11
|
DATA_VERSION = 12
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
from . import data # noqa
|
from . import data # noqa
|
||||||
|
|
||||||
from . import patches
|
|
||||||
|
|
||||||
from .module import module
|
from .module import module
|
||||||
from .lion import Lion
|
from .lion import Lion
|
||||||
from . import blacklists
|
from . import blacklists
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ app_config = Table('AppConfig')
|
|||||||
|
|
||||||
user_config = RowTable(
|
user_config = RowTable(
|
||||||
'user_config',
|
'user_config',
|
||||||
('userid', 'timezone', 'topgg_vote_reminder', 'avatar_hash'),
|
('userid', 'timezone', 'topgg_vote_reminder', 'avatar_hash', 'gems'),
|
||||||
'userid',
|
'userid',
|
||||||
cache=TTLCache(5000, ttl=60*5)
|
cache=TTLCache(5000, ttl=60*5)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
"""
|
|
||||||
Temporary patches for the discord.py library to support new features of the discord API.
|
|
||||||
"""
|
|
||||||
from discord.http import Route, HTTPClient
|
|
||||||
from discord.abc import Messageable
|
|
||||||
from discord.utils import InvalidArgument
|
|
||||||
from discord import File, AllowedMentions
|
|
||||||
|
|
||||||
|
|
||||||
def send_message(self, channel_id, content, *, tts=False, embeds=None,
|
|
||||||
nonce=None, allowed_mentions=None, message_reference=None):
|
|
||||||
r = Route('POST', '/channels/{channel_id}/messages', channel_id=channel_id)
|
|
||||||
payload = {}
|
|
||||||
|
|
||||||
if content:
|
|
||||||
payload['content'] = content
|
|
||||||
|
|
||||||
if tts:
|
|
||||||
payload['tts'] = True
|
|
||||||
|
|
||||||
if embeds:
|
|
||||||
payload['embeds'] = embeds
|
|
||||||
|
|
||||||
if nonce:
|
|
||||||
payload['nonce'] = nonce
|
|
||||||
|
|
||||||
if allowed_mentions:
|
|
||||||
payload['allowed_mentions'] = allowed_mentions
|
|
||||||
|
|
||||||
if message_reference:
|
|
||||||
payload['message_reference'] = message_reference
|
|
||||||
|
|
||||||
return self.request(r, json=payload)
|
|
||||||
|
|
||||||
|
|
||||||
HTTPClient.send_message = send_message
|
|
||||||
|
|
||||||
|
|
||||||
async def send(self, content=None, *, tts=False, embed=None, embeds=None, file=None,
|
|
||||||
files=None, delete_after=None, nonce=None,
|
|
||||||
allowed_mentions=None, reference=None,
|
|
||||||
mention_author=None):
|
|
||||||
|
|
||||||
channel = await self._get_channel()
|
|
||||||
state = self._state
|
|
||||||
content = str(content) if content is not None else None
|
|
||||||
if embed is not None:
|
|
||||||
if embeds is not None:
|
|
||||||
embeds.append(embed)
|
|
||||||
else:
|
|
||||||
embeds = [embed]
|
|
||||||
embed = embed.to_dict()
|
|
||||||
if embeds is not None:
|
|
||||||
embeds = [embed.to_dict() for embed in embeds]
|
|
||||||
|
|
||||||
if allowed_mentions is not None:
|
|
||||||
if state.allowed_mentions is not None:
|
|
||||||
allowed_mentions = state.allowed_mentions.merge(allowed_mentions).to_dict()
|
|
||||||
else:
|
|
||||||
allowed_mentions = allowed_mentions.to_dict()
|
|
||||||
else:
|
|
||||||
allowed_mentions = state.allowed_mentions and state.allowed_mentions.to_dict()
|
|
||||||
|
|
||||||
if mention_author is not None:
|
|
||||||
allowed_mentions = allowed_mentions or AllowedMentions().to_dict()
|
|
||||||
allowed_mentions['replied_user'] = bool(mention_author)
|
|
||||||
|
|
||||||
if reference is not None:
|
|
||||||
try:
|
|
||||||
reference = reference.to_message_reference_dict()
|
|
||||||
except AttributeError:
|
|
||||||
raise InvalidArgument('reference parameter must be Message or MessageReference') from None
|
|
||||||
|
|
||||||
if file is not None and files is not None:
|
|
||||||
raise InvalidArgument('cannot pass both file and files parameter to send()')
|
|
||||||
|
|
||||||
if file is not None:
|
|
||||||
if not isinstance(file, File):
|
|
||||||
raise InvalidArgument('file parameter must be File')
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = await state.http.send_files(channel.id, files=[file], allowed_mentions=allowed_mentions,
|
|
||||||
content=content, tts=tts, embed=embed, nonce=nonce,
|
|
||||||
message_reference=reference)
|
|
||||||
finally:
|
|
||||||
file.close()
|
|
||||||
|
|
||||||
elif files is not None:
|
|
||||||
if len(files) > 10:
|
|
||||||
raise InvalidArgument('files parameter must be a list of up to 10 elements')
|
|
||||||
elif not all(isinstance(file, File) for file in files):
|
|
||||||
raise InvalidArgument('files parameter must be a list of File')
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = await state.http.send_files(channel.id, files=files, content=content, tts=tts,
|
|
||||||
embed=embed, nonce=nonce, allowed_mentions=allowed_mentions,
|
|
||||||
message_reference=reference)
|
|
||||||
finally:
|
|
||||||
for f in files:
|
|
||||||
f.close()
|
|
||||||
else:
|
|
||||||
data = await state.http.send_message(channel.id, content, tts=tts, embeds=embeds,
|
|
||||||
nonce=nonce, allowed_mentions=allowed_mentions,
|
|
||||||
message_reference=reference)
|
|
||||||
|
|
||||||
ret = state.create_message(channel=channel, data=data)
|
|
||||||
if delete_after is not None:
|
|
||||||
await ret.delete(delay=delete_after)
|
|
||||||
return ret
|
|
||||||
|
|
||||||
Messageable.send = send
|
|
||||||
@@ -95,6 +95,7 @@ class Table:
|
|||||||
Decorator to add a saved query to the table.
|
Decorator to add a saved query to the table.
|
||||||
"""
|
"""
|
||||||
self.queries[func.__name__] = func
|
self.queries[func.__name__] = func
|
||||||
|
return func
|
||||||
|
|
||||||
|
|
||||||
class Row:
|
class Row:
|
||||||
@@ -149,7 +150,7 @@ class Row:
|
|||||||
self._pending = None
|
self._pending = None
|
||||||
|
|
||||||
def _refresh(self):
|
def _refresh(self):
|
||||||
row = self.table.select_one_where(self.table.dict_from_id(self.rowid))
|
row = self.table.select_one_where(**self.table.dict_from_id(self.rowid))
|
||||||
if not row:
|
if not row:
|
||||||
raise ValueError("Refreshing a {} which no longer exists!".format(type(self).__name__))
|
raise ValueError("Refreshing a {} which no longer exists!".format(type(self).__name__))
|
||||||
self.data = row
|
self.data = row
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
from .logger import log, logger
|
from .logger import log, logger
|
||||||
|
|
||||||
|
from . import interactions
|
||||||
|
from . import patches
|
||||||
|
|
||||||
from .client import client
|
from .client import client
|
||||||
from .config import conf
|
from .config import conf
|
||||||
from .args import args
|
from .args import args
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
from discord import Intents
|
from discord import Intents
|
||||||
from cmdClient.cmdClient import cmdClient
|
from cmdClient.cmdClient import cmdClient
|
||||||
|
|
||||||
|
from . import patches
|
||||||
|
from .interactions import InteractionType
|
||||||
from .config import conf
|
from .config import conf
|
||||||
from .sharding import shard_number, shard_count
|
from .sharding import shard_number, shard_count
|
||||||
from LionContext import LionContext
|
from LionContext import LionContext
|
||||||
|
|
||||||
|
|
||||||
# Initialise client
|
# Initialise client
|
||||||
owners = [int(owner) for owner in conf.bot.getlist('owners')]
|
owners = [int(owner) for owner in conf.bot.getlist('owners')]
|
||||||
intents = Intents.all()
|
intents = Intents.all()
|
||||||
@@ -18,3 +21,14 @@ client = cmdClient(
|
|||||||
baseContext=LionContext
|
baseContext=LionContext
|
||||||
)
|
)
|
||||||
client.conf = conf
|
client.conf = conf
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: Could include client id here, or app id, to avoid multiple handling.
|
||||||
|
NOOP_ID = 'NOOP'
|
||||||
|
|
||||||
|
|
||||||
|
@client.add_after_event('interaction_create')
|
||||||
|
async def handle_noop_interaction(client, interaction):
|
||||||
|
if interaction.interaction_type in (InteractionType.MESSAGE_COMPONENT, InteractionType.MODAL_SUBMIT):
|
||||||
|
if interaction.custom_id == NOOP_ID:
|
||||||
|
interaction.ack()
|
||||||
|
|||||||
@@ -78,10 +78,6 @@ class Conf:
|
|||||||
self.default = self.config["DEFAULT"]
|
self.default = self.config["DEFAULT"]
|
||||||
self.section = MapDotProxy(self.config[self.section_name])
|
self.section = MapDotProxy(self.config[self.section_name])
|
||||||
self.bot = self.section
|
self.bot = self.section
|
||||||
self.emojis = MapDotProxy(
|
|
||||||
self.config['EMOJIS'] if 'EMOJIS' in self.config else self.section,
|
|
||||||
converter=configEmoji.from_str
|
|
||||||
)
|
|
||||||
|
|
||||||
# Config file recursion, read in configuration files specified in every "ALSO_READ" key.
|
# Config file recursion, read in configuration files specified in every "ALSO_READ" key.
|
||||||
more_to_read = self.section.getlist("ALSO_READ", [])
|
more_to_read = self.section.getlist("ALSO_READ", [])
|
||||||
@@ -94,6 +90,11 @@ class Conf:
|
|||||||
if path not in read and path not in more_to_read]
|
if path not in read and path not in more_to_read]
|
||||||
more_to_read.extend(new_paths)
|
more_to_read.extend(new_paths)
|
||||||
|
|
||||||
|
self.emojis = MapDotProxy(
|
||||||
|
self.config['EMOJIS'] if 'EMOJIS' in self.config else self.section,
|
||||||
|
converter=configEmoji.from_str
|
||||||
|
)
|
||||||
|
|
||||||
global conf
|
global conf
|
||||||
conf = self
|
conf = self
|
||||||
|
|
||||||
|
|||||||
5
bot/meta/interactions/__init__.py
Normal file
5
bot/meta/interactions/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from . import enums
|
||||||
|
from .interactions import _component_interaction_factory, Interaction, ComponentInteraction, ModalResponse
|
||||||
|
from .components import *
|
||||||
|
from .modals import *
|
||||||
|
from .manager import InteractionManager
|
||||||
190
bot/meta/interactions/components.py
Normal file
190
bot/meta/interactions/components.py
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import logging
|
||||||
|
import traceback
|
||||||
|
import asyncio
|
||||||
|
import uuid
|
||||||
|
import json
|
||||||
|
|
||||||
|
from .enums import ButtonStyle, InteractionType
|
||||||
|
|
||||||
|
|
||||||
|
class MessageComponent:
|
||||||
|
_type = None
|
||||||
|
interaction_type = InteractionType.MESSAGE_COMPONENT
|
||||||
|
|
||||||
|
def __init_(self, *args, **kwargs):
|
||||||
|
self.message = None
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def to_json(self):
|
||||||
|
return json.dumps(self.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
class ActionRow(MessageComponent):
|
||||||
|
_type = 1
|
||||||
|
|
||||||
|
def __init__(self, *components):
|
||||||
|
self.components = components
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
data = {
|
||||||
|
"type": self._type,
|
||||||
|
"components": [comp.to_dict() for comp in self.components]
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class AwaitableComponent:
|
||||||
|
interaction_type: InteractionType = None
|
||||||
|
|
||||||
|
async def wait_for(self, timeout=None, check=None):
|
||||||
|
from meta import client
|
||||||
|
|
||||||
|
def _check(interaction):
|
||||||
|
valid = True
|
||||||
|
valid = valid and interaction.interaction_type == self.interaction_type
|
||||||
|
valid = valid and interaction.custom_id == self.custom_id
|
||||||
|
valid = valid and (check is None or check(interaction))
|
||||||
|
return valid
|
||||||
|
|
||||||
|
return await client.wait_for('interaction_create', timeout=timeout, check=_check)
|
||||||
|
|
||||||
|
def add_callback(self, timeout=None, repeat=True, check=None, pass_args=(), pass_kwargs={}):
|
||||||
|
def wrapper(func):
|
||||||
|
async def _func(*args, **kwargs):
|
||||||
|
try:
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
from meta import client
|
||||||
|
full_traceback = traceback.format_exc()
|
||||||
|
|
||||||
|
client.log(
|
||||||
|
f"Caught an unhandled exception while executing interaction callback "
|
||||||
|
f"for interaction type '{self.interaction_type.name}' with id '{self.custom_id}'.\n"
|
||||||
|
f"{self!r}\n"
|
||||||
|
f"{func!r}\n"
|
||||||
|
f"{full_traceback}",
|
||||||
|
context=f"cid:{self.custom_id}",
|
||||||
|
level=logging.ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
async def wrapped():
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
button_press = await self.wait_for(timeout=timeout, check=check)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
break
|
||||||
|
asyncio.create_task(_func(button_press, *pass_args, **pass_kwargs))
|
||||||
|
if not repeat:
|
||||||
|
break
|
||||||
|
future = asyncio.create_task(wrapped())
|
||||||
|
return future
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
class Button(MessageComponent, AwaitableComponent):
|
||||||
|
_type = 2
|
||||||
|
|
||||||
|
def __init__(self, label, style=ButtonStyle.PRIMARY, custom_id=None, url=None, emoji=None, disabled=False):
|
||||||
|
if style == ButtonStyle.LINK:
|
||||||
|
if url is None:
|
||||||
|
raise ValueError("Link buttons must have a url")
|
||||||
|
custom_id = None
|
||||||
|
elif custom_id is None:
|
||||||
|
custom_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
self.label = label
|
||||||
|
self.style = style
|
||||||
|
self.custom_id = custom_id
|
||||||
|
self.url = url
|
||||||
|
|
||||||
|
self.emoji = emoji
|
||||||
|
self.disabled = disabled
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
data = {
|
||||||
|
"type": self._type,
|
||||||
|
"label": self.label,
|
||||||
|
"style": int(self.style)
|
||||||
|
}
|
||||||
|
if self.style == ButtonStyle.LINK:
|
||||||
|
data['url'] = self.url
|
||||||
|
else:
|
||||||
|
data['custom_id'] = self.custom_id
|
||||||
|
if self.emoji is not None:
|
||||||
|
# TODO: This only supports PartialEmoji, not Emoji
|
||||||
|
data['emoji'] = self.emoji.to_dict()
|
||||||
|
if self.disabled:
|
||||||
|
data['disabled'] = self.disabled
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class SelectOption:
|
||||||
|
def __init__(self, label, value, description, emoji=None, default=False):
|
||||||
|
self.label = label
|
||||||
|
self.value = value
|
||||||
|
self.description = description
|
||||||
|
self.emoji = emoji
|
||||||
|
self.default = default
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
data = {
|
||||||
|
"label": self.label,
|
||||||
|
"value": self.value,
|
||||||
|
"description": self.description,
|
||||||
|
}
|
||||||
|
if self.emoji:
|
||||||
|
data['emoji'] = self.emoji.to_dict()
|
||||||
|
if self.default:
|
||||||
|
data['default'] = self.default
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class SelectMenu(MessageComponent, AwaitableComponent):
|
||||||
|
_type = 3
|
||||||
|
|
||||||
|
def __init__(self, *options, custom_id=None, placeholder=None, min_values=None, max_values=None, disabled=False):
|
||||||
|
self.options = options
|
||||||
|
self.custom_id = custom_id or str(uuid.uuid4())
|
||||||
|
self.placeholder = placeholder
|
||||||
|
self.min_values = min_values
|
||||||
|
self.max_values = max_values
|
||||||
|
self.disabled = disabled
|
||||||
|
|
||||||
|
def set_default(self, value=None, index=None):
|
||||||
|
"""
|
||||||
|
Convenience method to set the default option.
|
||||||
|
"""
|
||||||
|
if index is not None and value is not None:
|
||||||
|
raise ValueError("Both index and value were supplied for the default.")
|
||||||
|
if index is not None:
|
||||||
|
for i, option in enumerate(self.options):
|
||||||
|
option.default = (i == index)
|
||||||
|
elif value is not None:
|
||||||
|
for option in self.options:
|
||||||
|
option.default = (option.value == value)
|
||||||
|
else:
|
||||||
|
for option in self.options:
|
||||||
|
option.default = False
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
data = {
|
||||||
|
"type": self._type,
|
||||||
|
'custom_id': self.custom_id,
|
||||||
|
'options': [option.to_dict() for option in self.options],
|
||||||
|
}
|
||||||
|
if self.placeholder:
|
||||||
|
data['placeholder'] = self.placeholder
|
||||||
|
if self.min_values:
|
||||||
|
data['min_values'] = self.min_values
|
||||||
|
if self.max_values:
|
||||||
|
data['max_values'] = self.max_values
|
||||||
|
if self.disabled:
|
||||||
|
data['disabled'] = self.disabled
|
||||||
|
return data
|
||||||
40
bot/meta/interactions/enums.py
Normal file
40
bot/meta/interactions/enums.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from enum import IntEnum
|
||||||
|
|
||||||
|
|
||||||
|
class InteractionType(IntEnum):
|
||||||
|
PING = 1
|
||||||
|
APPLICATION_COMMAND = 2
|
||||||
|
MESSAGE_COMPONENT = 3
|
||||||
|
APPLICATION_COMMAND_AUTOCOMPLETE = 4
|
||||||
|
MODAL_SUBMIT = 5
|
||||||
|
|
||||||
|
|
||||||
|
class ComponentType(IntEnum):
|
||||||
|
ACTIONROW = 1
|
||||||
|
BUTTON = 2
|
||||||
|
SELECTMENU = 3
|
||||||
|
TEXTINPUT = 4
|
||||||
|
|
||||||
|
|
||||||
|
class ButtonStyle(IntEnum):
|
||||||
|
PRIMARY = 1
|
||||||
|
SECONDARY = 2
|
||||||
|
SUCCESS = 3
|
||||||
|
DANGER = 4
|
||||||
|
LINK = 5
|
||||||
|
|
||||||
|
|
||||||
|
class TextInputStyle(IntEnum):
|
||||||
|
SHORT = 1
|
||||||
|
PARAGRAPH = 2
|
||||||
|
|
||||||
|
|
||||||
|
class InteractionCallback(IntEnum):
|
||||||
|
PONG = 1
|
||||||
|
CHANNEL_MESSAGE_WITH_SOURCE = 4
|
||||||
|
DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE = 5
|
||||||
|
DEFERRED_UPDATE_MESSAGE = 6
|
||||||
|
UPDATE_MESSAGE = 7
|
||||||
|
APPLICATION_COMMAND_AUTOCOMPLETE_RESULT = 8
|
||||||
|
MODAL = 9
|
||||||
|
|
||||||
168
bot/meta/interactions/interactions.py
Normal file
168
bot/meta/interactions/interactions.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import asyncio
|
||||||
|
from .enums import ComponentType, InteractionType, InteractionCallback
|
||||||
|
|
||||||
|
|
||||||
|
class Interaction:
|
||||||
|
__slots__ = (
|
||||||
|
'id',
|
||||||
|
'token',
|
||||||
|
'_state'
|
||||||
|
)
|
||||||
|
|
||||||
|
async def response(self, content=None, embed=None, embeds=None, components=None, ephemeral=None):
|
||||||
|
data = {}
|
||||||
|
if content is not None:
|
||||||
|
data['content'] = str(content)
|
||||||
|
|
||||||
|
if embed is not None:
|
||||||
|
embeds = embeds or []
|
||||||
|
embeds.append(embed)
|
||||||
|
|
||||||
|
if embeds is not None:
|
||||||
|
data['embeds'] = [embed.to_dict() for embed in embeds]
|
||||||
|
|
||||||
|
if components is not None:
|
||||||
|
data['components'] = [component.to_dict() for component in components]
|
||||||
|
|
||||||
|
if ephemeral is not None:
|
||||||
|
data['flags'] = 1 << 6
|
||||||
|
|
||||||
|
return await self._state.http.interaction_callback(
|
||||||
|
self.id,
|
||||||
|
self.token,
|
||||||
|
InteractionCallback.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
|
||||||
|
async def response_update(self, content=None, embed=None, embeds=None, components=None):
|
||||||
|
data = {}
|
||||||
|
if content is not None:
|
||||||
|
data['content'] = str(content)
|
||||||
|
|
||||||
|
if embed is not None:
|
||||||
|
embeds = embeds or []
|
||||||
|
embeds.append(embed)
|
||||||
|
|
||||||
|
if embeds is not None:
|
||||||
|
data['embeds'] = [embed.to_dict() for embed in embeds]
|
||||||
|
|
||||||
|
if components is not None:
|
||||||
|
data['components'] = [component.to_dict() for component in components]
|
||||||
|
|
||||||
|
return await self._state.http.interaction_callback(
|
||||||
|
self.id,
|
||||||
|
self.token,
|
||||||
|
InteractionCallback.UPDATE_MESSAGE,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
|
||||||
|
async def response_deferred(self):
|
||||||
|
return await self._state.http.interaction_callback(
|
||||||
|
self.id,
|
||||||
|
self.token,
|
||||||
|
InteractionCallback.DEFERRED_UPDATE_MESSAGE
|
||||||
|
)
|
||||||
|
|
||||||
|
async def response_modal(self, modal):
|
||||||
|
return await self._state.http.interaction_callback(
|
||||||
|
self.id,
|
||||||
|
self.token,
|
||||||
|
InteractionCallback.MODAL,
|
||||||
|
modal.to_dict()
|
||||||
|
)
|
||||||
|
|
||||||
|
def ack(self):
|
||||||
|
asyncio.create_task(self.response_deferred())
|
||||||
|
|
||||||
|
|
||||||
|
class ComponentInteraction(Interaction):
|
||||||
|
interaction_type = InteractionType.MESSAGE_COMPONENT
|
||||||
|
# TODO: Slots
|
||||||
|
|
||||||
|
def __init__(self, message, user, data, state):
|
||||||
|
self.message = message
|
||||||
|
self.user = user
|
||||||
|
|
||||||
|
self._state = state
|
||||||
|
|
||||||
|
self._from_data(data)
|
||||||
|
|
||||||
|
def _from_data(self, data):
|
||||||
|
self.id = data['id']
|
||||||
|
self.token = data['token']
|
||||||
|
self.application_id = data['application_id']
|
||||||
|
|
||||||
|
component_data = data['data']
|
||||||
|
|
||||||
|
self.component_type = ComponentType(component_data['component_type'])
|
||||||
|
self.custom_id = component_data.get('custom_id', None)
|
||||||
|
|
||||||
|
|
||||||
|
class ButtonPress(ComponentInteraction):
|
||||||
|
__slots__ = ()
|
||||||
|
|
||||||
|
|
||||||
|
class Selection(ComponentInteraction):
|
||||||
|
__slots__ = ('values',)
|
||||||
|
|
||||||
|
def _from_data(self, data):
|
||||||
|
super()._from_data(data)
|
||||||
|
self.values = data['data']['values']
|
||||||
|
|
||||||
|
@property
|
||||||
|
def value(self):
|
||||||
|
if len(self.values) > 1:
|
||||||
|
raise ValueError("Cannot use 'value' property on multi-selection.")
|
||||||
|
elif len(self.values) == 1:
|
||||||
|
return self.values[0]
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class ModalResponse(Interaction):
|
||||||
|
__slots__ = (
|
||||||
|
'message',
|
||||||
|
'user',
|
||||||
|
'_state'
|
||||||
|
'id',
|
||||||
|
'token',
|
||||||
|
'application_id',
|
||||||
|
'custom_id',
|
||||||
|
'values'
|
||||||
|
)
|
||||||
|
|
||||||
|
interaction_type = InteractionType.MODAL_SUBMIT
|
||||||
|
|
||||||
|
def __init__(self, message, user, data, state):
|
||||||
|
self.message = message
|
||||||
|
self.user = user
|
||||||
|
|
||||||
|
self._state = state
|
||||||
|
|
||||||
|
self._from_data(data)
|
||||||
|
|
||||||
|
def _from_data(self, data):
|
||||||
|
self.id = data['id']
|
||||||
|
self.token = data['token']
|
||||||
|
self.application_id = data['application_id']
|
||||||
|
|
||||||
|
component_data = data['data']
|
||||||
|
|
||||||
|
self.custom_id = component_data.get('custom_id', None)
|
||||||
|
|
||||||
|
values = {}
|
||||||
|
for row in component_data['components']:
|
||||||
|
for component in row['components']:
|
||||||
|
values[component['custom_id']] = component['value']
|
||||||
|
self.values = values
|
||||||
|
|
||||||
|
|
||||||
|
def _component_interaction_factory(data):
|
||||||
|
component_type = data['data']['component_type']
|
||||||
|
|
||||||
|
if component_type == ComponentType.BUTTON:
|
||||||
|
return ButtonPress
|
||||||
|
elif component_type == ComponentType.SELECTMENU:
|
||||||
|
return Selection
|
||||||
|
else:
|
||||||
|
return None
|
||||||
114
bot/meta/interactions/manager.py
Normal file
114
bot/meta/interactions/manager.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
|
||||||
|
class InteractionManager:
|
||||||
|
def __init__(self, timeout=600, extend=None):
|
||||||
|
self.futures = []
|
||||||
|
self.self_futures = []
|
||||||
|
|
||||||
|
self.cleanup_function = self._cleanup
|
||||||
|
self.timeout_function = self._timeout
|
||||||
|
self.close_function = self._close
|
||||||
|
|
||||||
|
self.timeout = timeout
|
||||||
|
self.extend = extend or timeout
|
||||||
|
self.expires_at = None
|
||||||
|
|
||||||
|
self.cleaned_up = asyncio.Event()
|
||||||
|
|
||||||
|
async def _timeout_loop(self):
|
||||||
|
diff = (self.expires_at - datetime.now()).total_seconds()
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(diff)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
diff = (self.expires_at - datetime.now()).total_seconds()
|
||||||
|
if diff <= 0:
|
||||||
|
asyncio.create_task(self.run_timeout())
|
||||||
|
break
|
||||||
|
|
||||||
|
def extend_timeout(self):
|
||||||
|
new_expiry = max(datetime.now() + timedelta(seconds=self.extend), self.expires_at)
|
||||||
|
self.expires_at = new_expiry
|
||||||
|
|
||||||
|
async def wait(self):
|
||||||
|
"""
|
||||||
|
Wait until the manager is "done".
|
||||||
|
That is, until all the futures are done, or `closed` is set.
|
||||||
|
"""
|
||||||
|
closed_task = asyncio.create_task(self.cleaned_up.wait())
|
||||||
|
futures_task = asyncio.create_task(asyncio.wait(self.futures))
|
||||||
|
await asyncio.wait((closed_task, futures_task), return_when=asyncio.FIRST_COMPLETED)
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
if self.timeout is not None:
|
||||||
|
self.expires_at = datetime.now() + timedelta(seconds=self.timeout)
|
||||||
|
self.self_futures.append(
|
||||||
|
asyncio.create_task(self._timeout_loop())
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *args):
|
||||||
|
if not self.cleaned_up.is_set():
|
||||||
|
await self.cleanup(exiting=True)
|
||||||
|
|
||||||
|
async def _cleanup(self, manager, timeout=False, closing=False, exiting=False, **kwargs):
|
||||||
|
for future in self.futures:
|
||||||
|
future.cancel()
|
||||||
|
for future in self.self_futures:
|
||||||
|
future.cancel()
|
||||||
|
self.cleaned_up.set()
|
||||||
|
|
||||||
|
def on_cleanup(self, func):
|
||||||
|
self.cleanup_function = func
|
||||||
|
return func
|
||||||
|
|
||||||
|
async def cleanup(self, **kwargs):
|
||||||
|
try:
|
||||||
|
await self.cleanup_function(self, **kwargs)
|
||||||
|
except Exception:
|
||||||
|
logging.debug("An error occurred while cleaning up the InteractionManager", exc_info=True)
|
||||||
|
|
||||||
|
async def _timeout(self, manager, **kwargs):
|
||||||
|
await self.cleanup(timeout=True, **kwargs)
|
||||||
|
|
||||||
|
def on_timeout(self, func):
|
||||||
|
self.timeout_function = func
|
||||||
|
return func
|
||||||
|
|
||||||
|
async def run_timeout(self):
|
||||||
|
try:
|
||||||
|
await self.timeout_function(self)
|
||||||
|
except Exception:
|
||||||
|
logging.debug("An error occurred while timing out the InteractionManager", exc_info=True)
|
||||||
|
|
||||||
|
async def close(self, **kwargs):
|
||||||
|
"""
|
||||||
|
Request closure of the manager.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await self.close_function(self, **kwargs)
|
||||||
|
except Exception:
|
||||||
|
logging.debug("An error occurred while closing the InteractionManager", exc_info=True)
|
||||||
|
|
||||||
|
def on_close(self, func):
|
||||||
|
self.close_function = func
|
||||||
|
return func
|
||||||
|
|
||||||
|
async def _close(self, manager, **kwargs):
|
||||||
|
await self.cleanup(closing=True, **kwargs)
|
||||||
|
|
||||||
|
def add_future(self, future):
|
||||||
|
self.futures.append(future)
|
||||||
|
return future
|
||||||
|
|
||||||
|
def register(self, component, callback, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Attaches the given awaitable interaction and adds the given callback.
|
||||||
|
"""
|
||||||
|
future = component.add_callback(*args, **kwargs)(callback)
|
||||||
|
self.add_future(future)
|
||||||
|
return component
|
||||||
54
bot/meta/interactions/modals.py
Normal file
54
bot/meta/interactions/modals.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from .enums import TextInputStyle, InteractionType
|
||||||
|
from .components import AwaitableComponent
|
||||||
|
|
||||||
|
|
||||||
|
class Modal(AwaitableComponent):
|
||||||
|
interaction_type = InteractionType.MODAL_SUBMIT
|
||||||
|
|
||||||
|
def __init__(self, title, *components, custom_id=None):
|
||||||
|
self.custom_id = custom_id or str(uuid.uuid4())
|
||||||
|
|
||||||
|
self.title = title
|
||||||
|
self.components = components
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
data = {
|
||||||
|
'title': self.title,
|
||||||
|
'custom_id': self.custom_id,
|
||||||
|
'components': [comp.to_dict() for comp in self.components]
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class TextInput:
|
||||||
|
_type = 4
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
label, placeholder=None, value=None, required=False,
|
||||||
|
style=TextInputStyle.SHORT, min_length=None, max_length=None,
|
||||||
|
custom_id=None
|
||||||
|
):
|
||||||
|
self.custom_id = custom_id or str(uuid.uuid4())
|
||||||
|
|
||||||
|
self.label = label
|
||||||
|
self.placeholder = placeholder
|
||||||
|
self.value = value
|
||||||
|
self.required = required
|
||||||
|
self.style = style
|
||||||
|
self.min_length = min_length
|
||||||
|
self.max_length = max_length
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
data = {
|
||||||
|
'type': self._type,
|
||||||
|
'custom_id': self.custom_id,
|
||||||
|
'style': int(self.style),
|
||||||
|
'label': self.label,
|
||||||
|
}
|
||||||
|
for key in ('min_length', 'max_length', 'required', 'value', 'placeholder'):
|
||||||
|
if (value := getattr(self, key)) is not None:
|
||||||
|
data[key] = value
|
||||||
|
return data
|
||||||
289
bot/meta/patches.py
Normal file
289
bot/meta/patches.py
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
"""
|
||||||
|
Temporary patches for the discord.py library to support new features of the discord API.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from json import JSONEncoder
|
||||||
|
|
||||||
|
from discord.state import ConnectionState
|
||||||
|
from discord.http import Route, HTTPClient
|
||||||
|
from discord.abc import Messageable
|
||||||
|
from discord.utils import InvalidArgument, _get_as_snowflake, to_json
|
||||||
|
from discord import File, AllowedMentions, Member, User, Message
|
||||||
|
|
||||||
|
from .interactions import _component_interaction_factory, ModalResponse
|
||||||
|
from .interactions.enums import InteractionType
|
||||||
|
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _default(self, obj):
|
||||||
|
return getattr(obj.__class__, "to_json", _default.default)(obj)
|
||||||
|
|
||||||
|
|
||||||
|
_default.default = JSONEncoder().default
|
||||||
|
JSONEncoder.default = _default
|
||||||
|
|
||||||
|
|
||||||
|
def send_message(self, channel_id, content, *, tts=False, embeds=None,
|
||||||
|
nonce=None, allowed_mentions=None, message_reference=None, components=None):
|
||||||
|
r = Route('POST', '/channels/{channel_id}/messages', channel_id=channel_id)
|
||||||
|
payload = {}
|
||||||
|
|
||||||
|
if content:
|
||||||
|
payload['content'] = content
|
||||||
|
|
||||||
|
if tts:
|
||||||
|
payload['tts'] = True
|
||||||
|
|
||||||
|
if embeds:
|
||||||
|
payload['embeds'] = embeds
|
||||||
|
|
||||||
|
if nonce:
|
||||||
|
payload['nonce'] = nonce
|
||||||
|
|
||||||
|
if allowed_mentions:
|
||||||
|
payload['allowed_mentions'] = allowed_mentions
|
||||||
|
|
||||||
|
if message_reference:
|
||||||
|
payload['message_reference'] = message_reference
|
||||||
|
|
||||||
|
if components:
|
||||||
|
payload['components'] = components
|
||||||
|
|
||||||
|
return self.request(r, json=payload)
|
||||||
|
|
||||||
|
|
||||||
|
def send_files(
|
||||||
|
self,
|
||||||
|
channel_id, *,
|
||||||
|
files,
|
||||||
|
content=None, tts=False, embed=None, embeds=None, nonce=None, allowed_mentions=None, message_reference=None,
|
||||||
|
components=None
|
||||||
|
):
|
||||||
|
r = Route('POST', '/channels/{channel_id}/messages', channel_id=channel_id)
|
||||||
|
form = []
|
||||||
|
|
||||||
|
payload = {'tts': tts}
|
||||||
|
if content:
|
||||||
|
payload['content'] = content
|
||||||
|
if embed:
|
||||||
|
payload['embed'] = embed
|
||||||
|
if embeds:
|
||||||
|
payload['embeds'] = embeds
|
||||||
|
if nonce:
|
||||||
|
payload['nonce'] = nonce
|
||||||
|
if allowed_mentions:
|
||||||
|
payload['allowed_mentions'] = allowed_mentions
|
||||||
|
if message_reference:
|
||||||
|
payload['message_reference'] = message_reference
|
||||||
|
if components:
|
||||||
|
payload['components'] = components
|
||||||
|
|
||||||
|
form.append({'name': 'payload_json', 'value': to_json(payload)})
|
||||||
|
if len(files) == 1:
|
||||||
|
file = files[0]
|
||||||
|
form.append({
|
||||||
|
'name': 'file',
|
||||||
|
'value': file.fp,
|
||||||
|
'filename': file.filename,
|
||||||
|
'content_type': 'application/octet-stream'
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
for index, file in enumerate(files):
|
||||||
|
form.append({
|
||||||
|
'name': 'file%s' % index,
|
||||||
|
'value': file.fp,
|
||||||
|
'filename': file.filename,
|
||||||
|
'content_type': 'application/octet-stream'
|
||||||
|
})
|
||||||
|
|
||||||
|
return self.request(r, form=form, files=files)
|
||||||
|
|
||||||
|
|
||||||
|
def interaction_callback(self, interaction_id, interaction_token, callback_type, callback_data=None):
|
||||||
|
r = Route(
|
||||||
|
'POST',
|
||||||
|
'/interactions/{interaction_id}/{interaction_token}/callback',
|
||||||
|
interaction_id=interaction_id,
|
||||||
|
interaction_token=interaction_token
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = {}
|
||||||
|
|
||||||
|
payload['type'] = int(callback_type)
|
||||||
|
if callback_data:
|
||||||
|
payload['data'] = callback_data
|
||||||
|
|
||||||
|
return self.request(r, json=payload)
|
||||||
|
|
||||||
|
|
||||||
|
def interaction_edit(self, application_id, interaction_token, content=None, embed=None, embeds=None, components=None):
|
||||||
|
r = Route(
|
||||||
|
'PATCH',
|
||||||
|
'/webhooks/{application_id}/{interaction_token}/messages/@original',
|
||||||
|
application_id=application_id,
|
||||||
|
interaction_token=interaction_token
|
||||||
|
)
|
||||||
|
payload = {}
|
||||||
|
if content is not None:
|
||||||
|
payload['content'] = str(content)
|
||||||
|
|
||||||
|
if embed is not None:
|
||||||
|
embeds = embeds or []
|
||||||
|
embeds.append(embed)
|
||||||
|
|
||||||
|
if embeds is not None:
|
||||||
|
payload['embeds'] = [embed.to_dict() for embed in embeds]
|
||||||
|
|
||||||
|
if components is not None:
|
||||||
|
payload['components'] = [component.to_dict() for component in components]
|
||||||
|
|
||||||
|
return self.request(r, json=payload)
|
||||||
|
|
||||||
|
|
||||||
|
def edit_message(self, channel_id, message_id, components=None, **fields):
|
||||||
|
r = Route('PATCH', '/channels/{channel_id}/messages/{message_id}', channel_id=channel_id, message_id=message_id)
|
||||||
|
if components is not None:
|
||||||
|
fields['components'] = [comp.to_dict() for comp in components]
|
||||||
|
return self.request(r, json=fields)
|
||||||
|
|
||||||
|
|
||||||
|
HTTPClient.send_files = send_files
|
||||||
|
HTTPClient.send_message = send_message
|
||||||
|
HTTPClient.edit_message = edit_message
|
||||||
|
HTTPClient.interaction_callback = interaction_callback
|
||||||
|
HTTPClient.interaction_edit = interaction_edit
|
||||||
|
|
||||||
|
|
||||||
|
async def send(self, content=None, *, tts=False, embed=None, embeds=None, file=None,
|
||||||
|
files=None, delete_after=None, nonce=None,
|
||||||
|
allowed_mentions=None, reference=None,
|
||||||
|
mention_author=None, components=None):
|
||||||
|
|
||||||
|
channel = await self._get_channel()
|
||||||
|
state = self._state
|
||||||
|
content = str(content) if content is not None else None
|
||||||
|
if embed is not None:
|
||||||
|
if embeds is not None:
|
||||||
|
embeds.append(embed)
|
||||||
|
else:
|
||||||
|
embeds = [embed]
|
||||||
|
embed = embed.to_dict()
|
||||||
|
if embeds is not None:
|
||||||
|
embeds = [embed.to_dict() for embed in embeds]
|
||||||
|
|
||||||
|
if components is not None:
|
||||||
|
components = [comp.to_dict() for comp in components]
|
||||||
|
|
||||||
|
if allowed_mentions is not None:
|
||||||
|
if state.allowed_mentions is not None:
|
||||||
|
allowed_mentions = state.allowed_mentions.merge(allowed_mentions).to_dict()
|
||||||
|
else:
|
||||||
|
allowed_mentions = allowed_mentions.to_dict()
|
||||||
|
else:
|
||||||
|
allowed_mentions = state.allowed_mentions and state.allowed_mentions.to_dict()
|
||||||
|
|
||||||
|
if mention_author is not None:
|
||||||
|
allowed_mentions = allowed_mentions or AllowedMentions().to_dict()
|
||||||
|
allowed_mentions['replied_user'] = bool(mention_author)
|
||||||
|
|
||||||
|
if reference is not None:
|
||||||
|
try:
|
||||||
|
reference = reference.to_message_reference_dict()
|
||||||
|
except AttributeError:
|
||||||
|
raise InvalidArgument('reference parameter must be Message or MessageReference') from None
|
||||||
|
|
||||||
|
if file is not None and files is not None:
|
||||||
|
raise InvalidArgument('cannot pass both file and files parameter to send()')
|
||||||
|
|
||||||
|
if file is not None:
|
||||||
|
if not isinstance(file, File):
|
||||||
|
raise InvalidArgument('file parameter must be File')
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = await state.http.send_files(channel.id, files=[file], allowed_mentions=allowed_mentions,
|
||||||
|
content=content, tts=tts, embed=embed, nonce=nonce,
|
||||||
|
message_reference=reference, components=components)
|
||||||
|
finally:
|
||||||
|
file.close()
|
||||||
|
|
||||||
|
elif files is not None:
|
||||||
|
if len(files) > 10:
|
||||||
|
raise InvalidArgument('files parameter must be a list of up to 10 elements')
|
||||||
|
elif not all(isinstance(file, File) for file in files):
|
||||||
|
raise InvalidArgument('files parameter must be a list of File')
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = await state.http.send_files(channel.id, files=files, content=content, tts=tts,
|
||||||
|
embeds=embeds, nonce=nonce, allowed_mentions=allowed_mentions,
|
||||||
|
message_reference=reference, components=components)
|
||||||
|
finally:
|
||||||
|
for f in files:
|
||||||
|
f.close()
|
||||||
|
else:
|
||||||
|
data = await state.http.send_message(channel.id, content, tts=tts, embeds=embeds,
|
||||||
|
nonce=nonce, allowed_mentions=allowed_mentions,
|
||||||
|
message_reference=reference, components=components)
|
||||||
|
|
||||||
|
ret = state.create_message(channel=channel, data=data)
|
||||||
|
if delete_after is not None:
|
||||||
|
await ret.delete(delay=delete_after)
|
||||||
|
return ret
|
||||||
|
|
||||||
|
Messageable.send = send
|
||||||
|
|
||||||
|
|
||||||
|
def parse_interaction_create(self, data):
|
||||||
|
self.dispatch('raw_interaction_create', data)
|
||||||
|
|
||||||
|
if (guild_id := data.get('guild_id', None)):
|
||||||
|
guild = self._get_guild(int(guild_id))
|
||||||
|
if guild is None:
|
||||||
|
log.debug('INTERACTION_CREATE referencing an unknown guild ID: %s. Discarding.', guild_id)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
guild = None
|
||||||
|
|
||||||
|
if (member_data := data.get('member', None)) is not None:
|
||||||
|
# Construct member
|
||||||
|
# TODO: Theoretical reliance on cached guild
|
||||||
|
user = Member(data=member_data, guild=guild, state=self)
|
||||||
|
else:
|
||||||
|
# Assume user
|
||||||
|
user = self.get_user(_get_as_snowflake(data['user'], 'id')) or User(data=data['user'], state=self)
|
||||||
|
|
||||||
|
if 'message' in data:
|
||||||
|
message = self._get_message(_get_as_snowflake(data['message'], 'id'))
|
||||||
|
if not message:
|
||||||
|
message_data = data['message']
|
||||||
|
channel, _ = self._get_guild_channel(message_data)
|
||||||
|
message = Message(data=message_data, channel=channel, state=self)
|
||||||
|
if self._messages is not None:
|
||||||
|
self._messages.append(message)
|
||||||
|
else:
|
||||||
|
message = None
|
||||||
|
|
||||||
|
interaction = None
|
||||||
|
if data['type'] == InteractionType.MESSAGE_COMPONENT:
|
||||||
|
interaction_class = _component_interaction_factory(data)
|
||||||
|
if interaction_class:
|
||||||
|
interaction = interaction_class(message, user, data, self)
|
||||||
|
else:
|
||||||
|
log.debug(
|
||||||
|
'INTERACTION_CREATE recieved unhandled message component interaction type: %s',
|
||||||
|
data['data']['component_type']
|
||||||
|
)
|
||||||
|
elif data['type'] == InteractionType.MODAL_SUBMIT:
|
||||||
|
interaction = ModalResponse(message, user, data, self)
|
||||||
|
else:
|
||||||
|
log.debug('INTERACTION_CREATE recieved unhandled interaction type: %s', data['type'])
|
||||||
|
log.debug(data)
|
||||||
|
interaction = None
|
||||||
|
|
||||||
|
if interaction:
|
||||||
|
self.dispatch('interaction_create', interaction)
|
||||||
|
|
||||||
|
|
||||||
|
ConnectionState.parse_interaction_create = parse_interaction_create
|
||||||
@@ -2,6 +2,7 @@ from typing import List, Dict
|
|||||||
import datetime
|
import datetime
|
||||||
import discord
|
import discord
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import random
|
||||||
|
|
||||||
from settings import GuildSettings
|
from settings import GuildSettings
|
||||||
from utils.lib import tick, cross
|
from utils.lib import tick, cross
|
||||||
@@ -364,6 +365,8 @@ class TimeSlot:
|
|||||||
Start the accountability room slot.
|
Start the accountability room slot.
|
||||||
Update the status message, and launch the DM reminder.
|
Update the status message, and launch the DM reminder.
|
||||||
"""
|
"""
|
||||||
|
dither = 15 * random.random()
|
||||||
|
await asyncio.sleep(dither)
|
||||||
if self.channel:
|
if self.channel:
|
||||||
try:
|
try:
|
||||||
await self.channel.edit(name="Scheduled Session Room")
|
await self.channel.edit(name="Scheduled Session Room")
|
||||||
@@ -408,6 +411,8 @@ class TimeSlot:
|
|||||||
Delete the channel and update the status message to display a session summary.
|
Delete the channel and update the status message to display a session summary.
|
||||||
Unloads the TimeSlot from cache.
|
Unloads the TimeSlot from cache.
|
||||||
"""
|
"""
|
||||||
|
dither = 15 * random.random()
|
||||||
|
await asyncio.sleep(dither)
|
||||||
if self.channel:
|
if self.channel:
|
||||||
try:
|
try:
|
||||||
await self.channel.delete()
|
await self.channel.delete()
|
||||||
|
|||||||
@@ -21,26 +21,27 @@ group_hints = {
|
|||||||
'Personal Settings': "*Tell me about yourself!*",
|
'Personal Settings': "*Tell me about yourself!*",
|
||||||
'Guild Admin': "*Dangerous administration commands!*",
|
'Guild Admin': "*Dangerous administration commands!*",
|
||||||
'Guild Configuration': "*Control how I behave in your server.*",
|
'Guild Configuration': "*Control how I behave in your server.*",
|
||||||
'Meta': "*Information about me!*"
|
'Meta': "*Information about me!*",
|
||||||
|
'Support Us': "*Support the team and keep the project alive by using LionGems!*"
|
||||||
}
|
}
|
||||||
|
|
||||||
standard_group_order = (
|
standard_group_order = (
|
||||||
('Pomodoro', 'Productivity', 'Statistics', 'Economy', 'Personal Settings', 'Meta'),
|
('Pomodoro', 'Productivity', 'Support Us', 'Statistics', 'Economy', 'Personal Settings', 'Meta'),
|
||||||
)
|
)
|
||||||
|
|
||||||
mod_group_order = (
|
mod_group_order = (
|
||||||
('Moderation', 'Meta'),
|
('Moderation', 'Meta'),
|
||||||
('Pomodoro', 'Productivity', 'Statistics', 'Economy', 'Personal Settings')
|
('Pomodoro', 'Productivity', 'Support Us', 'Statistics', 'Economy', 'Personal Settings')
|
||||||
)
|
)
|
||||||
|
|
||||||
admin_group_order = (
|
admin_group_order = (
|
||||||
('Guild Admin', 'Guild Configuration', 'Moderation', 'Meta'),
|
('Guild Admin', 'Guild Configuration', 'Moderation', 'Meta'),
|
||||||
('Pomodoro', 'Productivity', 'Statistics', 'Economy', 'Personal Settings')
|
('Pomodoro', 'Productivity', 'Support Us', 'Statistics', 'Economy', 'Personal Settings')
|
||||||
)
|
)
|
||||||
|
|
||||||
bot_admin_group_order = (
|
bot_admin_group_order = (
|
||||||
('Bot Admin', 'Guild Admin', 'Guild Configuration', 'Moderation', 'Meta'),
|
('Bot Admin', 'Guild Admin', 'Guild Configuration', 'Moderation', 'Meta'),
|
||||||
('Pomodoro', 'Productivity', 'Statistics', 'Economy', 'Personal Settings')
|
('Pomodoro', 'Productivity', 'Support Us', 'Statistics', 'Economy', 'Personal Settings')
|
||||||
)
|
)
|
||||||
|
|
||||||
# Help embed format
|
# Help embed format
|
||||||
|
|||||||
@@ -16,7 +16,13 @@ sponsored_commands = {'profile', 'stats', 'weekly', 'monthly'}
|
|||||||
async def sponsor_reply_wrapper(func, ctx: LionContext, *args, **kwargs):
|
async def sponsor_reply_wrapper(func, ctx: LionContext, *args, **kwargs):
|
||||||
if ctx.cmd and ctx.cmd.name in sponsored_commands:
|
if ctx.cmd and ctx.cmd.name in sponsored_commands:
|
||||||
if (prompt := ctx.client.settings.sponsor_prompt.value):
|
if (prompt := ctx.client.settings.sponsor_prompt.value):
|
||||||
if not ctx.guild or ctx.guild.id not in ctx.client.settings.sponsor_guild_whitelist.value:
|
if ctx.guild:
|
||||||
|
show = ctx.guild.id not in ctx.client.settings.sponsor_guild_whitelist.value
|
||||||
|
show = show and not ctx.client.data.premium_guilds.queries.fetch_guild(ctx.guild.id)
|
||||||
|
else:
|
||||||
|
show = True
|
||||||
|
|
||||||
|
if show:
|
||||||
sponsor_hint = discord.Embed(
|
sponsor_hint = discord.Embed(
|
||||||
description=prompt,
|
description=prompt,
|
||||||
colour=discord.Colour.dark_theme()
|
colour=discord.Colour.dark_theme()
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ class Timer:
|
|||||||
self.last_seen[member.id] = utc_now()
|
self.last_seen[member.id] = utc_now()
|
||||||
|
|
||||||
content = []
|
content = []
|
||||||
|
|
||||||
if to_kick:
|
if to_kick:
|
||||||
# Do kick
|
# Do kick
|
||||||
await asyncio.gather(
|
await asyncio.gather(
|
||||||
@@ -194,9 +195,12 @@ class Timer:
|
|||||||
old_reaction_message = self.reaction_message
|
old_reaction_message = self.reaction_message
|
||||||
|
|
||||||
# Send status image, add reaction
|
# Send status image, add reaction
|
||||||
|
args = await self.status()
|
||||||
|
if status_content := args.pop('content', None):
|
||||||
|
content.append(status_content)
|
||||||
self.reaction_message = await self.text_channel.send(
|
self.reaction_message = await self.text_channel.send(
|
||||||
content='\n'.join(content),
|
content='\n'.join(content),
|
||||||
**(await self.status())
|
**args
|
||||||
)
|
)
|
||||||
await self.reaction_message.add_reaction('✅')
|
await self.reaction_message.add_reaction('✅')
|
||||||
|
|
||||||
@@ -386,7 +390,7 @@ class Timer:
|
|||||||
|
|
||||||
if self._state.end < utc_now():
|
if self._state.end < utc_now():
|
||||||
asyncio.create_task(self.notify_change_stage(self._state, self.current_stage))
|
asyncio.create_task(self.notify_change_stage(self._state, self.current_stage))
|
||||||
else:
|
elif self.members:
|
||||||
asyncio.create_task(self._update_channel_name())
|
asyncio.create_task(self._update_channel_name())
|
||||||
asyncio.create_task(self.update_last_status())
|
asyncio.create_task(self.update_last_status())
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import discord
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from cmdClient.lib import SafeCancellation
|
from cmdClient.lib import SafeCancellation
|
||||||
from meta import client
|
from meta import client, conf
|
||||||
from core import Lion
|
from core import Lion
|
||||||
from data import NULL, NOTNULL
|
from data import NULL, NOTNULL
|
||||||
from settings import GuildSettings
|
from settings import GuildSettings
|
||||||
@@ -26,11 +26,11 @@ class Tasklist:
|
|||||||
checkmark = "✔"
|
checkmark = "✔"
|
||||||
block_size = 15
|
block_size = 15
|
||||||
|
|
||||||
next_emoji = "▶"
|
next_emoji = conf.emojis.forward
|
||||||
prev_emoji = "◀"
|
prev_emoji = conf.emojis.backward
|
||||||
question_emoji = "❔"
|
question_emoji = conf.emojis.question
|
||||||
cancel_emoji = "❌"
|
cancel_emoji = conf.emojis.cancel
|
||||||
refresh_emoji = "🔄"
|
refresh_emoji = conf.emojis.refresh
|
||||||
|
|
||||||
paged_reaction_order = (
|
paged_reaction_order = (
|
||||||
prev_emoji, cancel_emoji, question_emoji, refresh_emoji, next_emoji
|
prev_emoji, cancel_emoji, question_emoji, refresh_emoji, next_emoji
|
||||||
@@ -121,7 +121,7 @@ class Tasklist:
|
|||||||
self.active[(member.id, channel.id)] = self
|
self.active[(member.id, channel.id)] = self
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def fetch_or_create(cls, member, channel):
|
def fetch_or_create(cls, ctx, flags, member, channel):
|
||||||
tasklist = cls.active.get((member.id, channel.id), None)
|
tasklist = cls.active.get((member.id, channel.id), None)
|
||||||
return tasklist if tasklist is not None else cls(member, channel)
|
return tasklist if tasklist is not None else cls(member, channel)
|
||||||
|
|
||||||
@@ -574,7 +574,7 @@ class Tasklist:
|
|||||||
"""
|
"""
|
||||||
Reaction handler for reactions on our message.
|
Reaction handler for reactions on our message.
|
||||||
"""
|
"""
|
||||||
str_emoji = str(reaction.emoji)
|
str_emoji = reaction.emoji
|
||||||
if added and str_emoji in self.paged_reaction_order:
|
if added and str_emoji in self.paged_reaction_order:
|
||||||
# Attempt to remove reaction
|
# Attempt to remove reaction
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from .Tasklist import Tasklist
|
|||||||
name="todo",
|
name="todo",
|
||||||
desc="Display and edit your personal To-Do list.",
|
desc="Display and edit your personal To-Do list.",
|
||||||
group="Productivity",
|
group="Productivity",
|
||||||
flags=('add==', 'delete==', 'check==', 'uncheck==', 'edit==')
|
flags=('add==', 'delete==', 'check==', 'uncheck==', 'edit==', 'text')
|
||||||
)
|
)
|
||||||
@in_guild()
|
@in_guild()
|
||||||
async def cmd_todo(ctx, flags):
|
async def cmd_todo(ctx, flags):
|
||||||
@@ -69,7 +69,7 @@ async def cmd_todo(ctx, flags):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# TODO: Custom module, with pre-command hooks
|
# TODO: Custom module, with pre-command hooks
|
||||||
tasklist = Tasklist.fetch_or_create(ctx.author, ctx.ch)
|
tasklist = Tasklist.fetch_or_create(ctx, flags, ctx.author, ctx.ch)
|
||||||
|
|
||||||
keys = {
|
keys = {
|
||||||
'add': (('add', ), tasklist.parse_add),
|
'add': (('add', ), tasklist.parse_add),
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ async def topgg_reply_wrapper(func, ctx: LionContext, *args, suggest_vote=True,
|
|||||||
pass
|
pass
|
||||||
elif ctx.guild and ctx.guild.id in ctx.client.settings.topgg_guild_whitelist.value:
|
elif ctx.guild and ctx.guild.id in ctx.client.settings.topgg_guild_whitelist.value:
|
||||||
pass
|
pass
|
||||||
|
elif ctx.guild and ctx.client.data.premium_guilds.queries.fetch_guild(ctx.guild.id):
|
||||||
|
pass
|
||||||
elif not get_last_voted_timestamp(ctx.author.id):
|
elif not get_last_voted_timestamp(ctx.author.id):
|
||||||
upvote_info_formatted = upvote_info.format(lion_yayemote, ctx.best_prefix, lion_loveemote)
|
upvote_info_formatted = upvote_info.format(lion_yayemote, ctx.best_prefix, lion_loveemote)
|
||||||
|
|
||||||
|
|||||||
44
config/emojis.conf
Normal file
44
config/emojis.conf
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
[EMOJIS]
|
||||||
|
lionyay = <:lionyay:933610591388581890>
|
||||||
|
lionlove = <:lionlove:933610591459872868>
|
||||||
|
|
||||||
|
progress_left_empty = <:1dark:933826583934959677>
|
||||||
|
progress_left_full = <:3_:933820201840021554>
|
||||||
|
progress_middle_full = <:5_:933823492221173860>
|
||||||
|
progress_middle_transition = <:6_:933824739414278184>
|
||||||
|
progress_middle_empty = <:7_:933825425631752263>
|
||||||
|
progress_right_empty = <:8fixed:933827434950844468>
|
||||||
|
progress_right_full = <:9full:933828043548524634>
|
||||||
|
|
||||||
|
inactive_achievement_1 = <:1:936107534748635166>
|
||||||
|
inactive_achievement_2 = <:2:936107534815735879>
|
||||||
|
inactive_achievement_3 = <:3:936107534782193704>
|
||||||
|
inactive_achievement_4 = <:4:936107534358577203>
|
||||||
|
inactive_achievement_5 = <:5:936107534715088926>
|
||||||
|
inactive_achievement_6 = <:6:936107534169833483>
|
||||||
|
inactive_achievement_7 = <:7:936107534723448832>
|
||||||
|
inactive_achievement_8 = <:8:936107534706688070>
|
||||||
|
|
||||||
|
active_achievement_1 = <:a1_:936107582786011196>
|
||||||
|
active_achievement_2 = <:a2_:936107582693720104>
|
||||||
|
active_achievement_3 = <:a3_:936107582760824862>
|
||||||
|
active_achievement_4 = <:a4_:936107582614028348>
|
||||||
|
active_achievement_5 = <:a5_:936107582630809620>
|
||||||
|
active_achievement_6 = <:a6_:936107582609821726>
|
||||||
|
active_achievement_7 = <:a7_:936107582546935818>
|
||||||
|
active_achievement_8 = <:a8_:936107582626623498>
|
||||||
|
|
||||||
|
|
||||||
|
gem = <:gem:975880967891845210>
|
||||||
|
|
||||||
|
skin_equipped = <:checkmarkthinbigger:975880828494151711>
|
||||||
|
skin_owned = <:greycheckmarkthinbigger:975880828485767198>
|
||||||
|
skin_unowned = <:newbigger:975880828712288276>
|
||||||
|
|
||||||
|
backward = <:arrowbackwardbigger:975880828460597288>
|
||||||
|
forward = <:arrowforwardbigger:975880828624199710>
|
||||||
|
person = <:person01:975880828481581096>
|
||||||
|
|
||||||
|
question = <:questionmarkbigger:975880828645167154>
|
||||||
|
cancel = <:xbigger:975880828653568012>
|
||||||
|
refresh = <:cyclebigger:975880828611600404>
|
||||||
@@ -3,6 +3,7 @@ log_file = bot.log
|
|||||||
log_channel =
|
log_channel =
|
||||||
error_channel =
|
error_channel =
|
||||||
guild_log_channel =
|
guild_log_channel =
|
||||||
|
gem_transaction_channel =
|
||||||
|
|
||||||
prefix = !
|
prefix = !
|
||||||
token =
|
token =
|
||||||
@@ -22,33 +23,4 @@ topgg_port =
|
|||||||
invite_link = https://discord.studylions.com/invite
|
invite_link = https://discord.studylions.com/invite
|
||||||
support_link = https://discord.gg/StudyLions
|
support_link = https://discord.gg/StudyLions
|
||||||
|
|
||||||
[EMOJIS]
|
ALSO_READ = config/emojis.conf
|
||||||
lionyay =
|
|
||||||
lionlove =
|
|
||||||
|
|
||||||
progress_left_empty =
|
|
||||||
progress_left_full =
|
|
||||||
progress_middle_empty =
|
|
||||||
progress_middle_full =
|
|
||||||
progress_middle_transition =
|
|
||||||
progress_right_empty =
|
|
||||||
progress_right_full =
|
|
||||||
|
|
||||||
|
|
||||||
inactive_achievement_1 =
|
|
||||||
inactive_achievement_2 =
|
|
||||||
inactive_achievement_3 =
|
|
||||||
inactive_achievement_4 =
|
|
||||||
inactive_achievement_5 =
|
|
||||||
inactive_achievement_6 =
|
|
||||||
inactive_achievement_7 =
|
|
||||||
inactive_achievement_8 =
|
|
||||||
|
|
||||||
active_achievement_1 =
|
|
||||||
active_achievement_2 =
|
|
||||||
active_achievement_3 =
|
|
||||||
active_achievement_4 =
|
|
||||||
active_achievement_5 =
|
|
||||||
active_achievement_6 =
|
|
||||||
active_achievement_7 =
|
|
||||||
active_achievement_8 =
|
|
||||||
|
|||||||
108
data/migration/v11-v12/migration.sql
Normal file
108
data/migration/v11-v12/migration.sql
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
-- Add gem support
|
||||||
|
ALTER TABLE user_config ADD COLUMN gems INTEGER DEFAULT 0;
|
||||||
|
|
||||||
|
-- LionGem audit log {{{
|
||||||
|
CREATE TYPE GemTransactionType AS ENUM (
|
||||||
|
'ADMIN',
|
||||||
|
'GIFT',
|
||||||
|
'PURCHASE',
|
||||||
|
'AUTOMATIC'
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
-- }}}
|
||||||
|
|
||||||
|
-- Skin Data {{{
|
||||||
|
CREATE TABLE global_available_skins(
|
||||||
|
skin_id SERIAL PRIMARY KEY,
|
||||||
|
skin_name TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX global_available_skin_names ON global_available_skins (skin_name);
|
||||||
|
|
||||||
|
CREATE TABLE customised_skins(
|
||||||
|
custom_skin_id SERIAL PRIMARY KEY,
|
||||||
|
base_skin_id INTEGER REFERENCES global_available_skins (skin_id),
|
||||||
|
_timestamp TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE customised_skin_property_ids(
|
||||||
|
property_id SERIAL PRIMARY KEY,
|
||||||
|
card_id TEXT NOT NULL,
|
||||||
|
property_name TEXT NOT NULL,
|
||||||
|
UNIQUE(card_id, property_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE customised_skin_properties(
|
||||||
|
custom_skin_id INTEGER NOT NULL REFERENCES customised_skins (custom_skin_id),
|
||||||
|
property_id INTEGER NOT NULL REFERENCES customised_skin_property_ids (property_id),
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (custom_skin_id, property_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX customised_skin_property_skin_id ON customised_skin_properties(custom_skin_id);
|
||||||
|
|
||||||
|
CREATE VIEW customised_skin_data AS
|
||||||
|
SELECT
|
||||||
|
skins.custom_skin_id AS custom_skin_id,
|
||||||
|
skins.base_skin_id AS base_skin_id,
|
||||||
|
properties.property_id AS property_id,
|
||||||
|
prop_ids.card_id AS card_id,
|
||||||
|
prop_ids.property_name AS property_name,
|
||||||
|
properties.value AS value
|
||||||
|
FROM
|
||||||
|
customised_skins skins
|
||||||
|
LEFT JOIN customised_skin_properties properties ON skins.custom_skin_id = properties.custom_skin_id
|
||||||
|
LEFT JOIN customised_skin_property_ids prop_ids ON properties.property_id = prop_ids.property_id;
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE user_skin_inventory(
|
||||||
|
itemid SERIAL PRIMARY KEY,
|
||||||
|
userid BIGINT NOT NULL REFERENCES user_config (userid) ON DELETE CASCADE,
|
||||||
|
custom_skin_id INTEGER NOT NULL REFERENCES customised_skins (custom_skin_id) ON DELETE CASCADE,
|
||||||
|
transactionid INTEGER REFERENCES gem_transactions (transactionid),
|
||||||
|
active BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
acquired_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
expires_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
CREATE INDEX user_skin_inventory_users ON user_skin_inventory(userid);
|
||||||
|
CREATE UNIQUE INDEX user_skin_inventory_active ON user_skin_inventory(userid) WHERE active = TRUE;
|
||||||
|
|
||||||
|
CREATE VIEW user_active_skins AS
|
||||||
|
SELECT
|
||||||
|
*
|
||||||
|
FROM user_skin_inventory
|
||||||
|
WHERE active=True;
|
||||||
|
-- }}}
|
||||||
|
|
||||||
|
|
||||||
|
-- Premium Guild Data {{{
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Contributions members have made to guild premium funds
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
-- }}}
|
||||||
|
|
||||||
|
INSERT INTO VersionHistory (version, author) VALUES (12, 'v11-v12 migration');
|
||||||
112
data/schema.sql
112
data/schema.sql
@@ -4,7 +4,7 @@ CREATE TABLE VersionHistory(
|
|||||||
time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
author TEXT
|
author TEXT
|
||||||
);
|
);
|
||||||
INSERT INTO VersionHistory (version, author) VALUES (11, 'Initial Creation');
|
INSERT INTO VersionHistory (version, author) VALUES (12, 'Initial Creation');
|
||||||
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION update_timestamp_column()
|
CREATE OR REPLACE FUNCTION update_timestamp_column()
|
||||||
@@ -48,9 +48,10 @@ CREATE TABLE global_guild_blacklist(
|
|||||||
CREATE TABLE user_config(
|
CREATE TABLE user_config(
|
||||||
userid BIGINT PRIMARY KEY,
|
userid BIGINT PRIMARY KEY,
|
||||||
timezone TEXT,
|
timezone TEXT,
|
||||||
topgg_vote_reminder,
|
topgg_vote_reminder BOOLEAN,
|
||||||
avatar_hash TEXT,
|
avatar_hash TEXT,
|
||||||
API_timestamp BIGINT
|
API_timestamp BIGINT,
|
||||||
|
gems INTEGER DEFAULT 0
|
||||||
);
|
);
|
||||||
-- }}}
|
-- }}}
|
||||||
|
|
||||||
@@ -821,4 +822,109 @@ CREATE TABLE sponsor_guild_whitelist(
|
|||||||
);
|
);
|
||||||
-- }}}
|
-- }}}
|
||||||
|
|
||||||
|
-- LionGem audit log {{{
|
||||||
|
CREATE TYPE GemTransactionType AS ENUM (
|
||||||
|
'ADMIN',
|
||||||
|
'GIFT',
|
||||||
|
'PURCHASE',
|
||||||
|
'AUTOMATIC'
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
-- }}}
|
||||||
|
|
||||||
|
-- Skin Data {{{
|
||||||
|
CREATE TABLE global_available_skins(
|
||||||
|
skin_id SERIAL PRIMARY KEY,
|
||||||
|
skin_name TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX global_available_skin_names ON global_available_skins (skin_name);
|
||||||
|
|
||||||
|
CREATE TABLE customised_skins(
|
||||||
|
custom_skin_id SERIAL PRIMARY KEY,
|
||||||
|
base_skin_id INTEGER REFERENCES global_available_skins (skin_id),
|
||||||
|
_timestamp TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE customised_skin_property_ids(
|
||||||
|
property_id SERIAL PRIMARY KEY,
|
||||||
|
card_id TEXT NOT NULL,
|
||||||
|
property_name TEXT NOT NULL,
|
||||||
|
UNIQUE(card_id, property_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE customised_skin_properties(
|
||||||
|
custom_skin_id INTEGER NOT NULL REFERENCES customised_skins (custom_skin_id),
|
||||||
|
property_id INTEGER NOT NULL REFERENCES customised_skin_property_ids (property_id),
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (custom_skin_id, property_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX customised_skin_property_skin_id ON customised_skin_properties(custom_skin_id);
|
||||||
|
|
||||||
|
CREATE VIEW customised_skin_data AS
|
||||||
|
SELECT
|
||||||
|
skins.custom_skin_id AS custom_skin_id,
|
||||||
|
skins.base_skin_id AS base_skin_id,
|
||||||
|
properties.property_id AS property_id,
|
||||||
|
prop_ids.card_id AS card_id,
|
||||||
|
prop_ids.property_name AS property_name,
|
||||||
|
properties.value AS value
|
||||||
|
FROM
|
||||||
|
customised_skins skins
|
||||||
|
LEFT JOIN customised_skin_properties properties ON skins.custom_skin_id = properties.custom_skin_id
|
||||||
|
LEFT JOIN customised_skin_property_ids prop_ids ON properties.property_id = prop_ids.property_id;
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE user_skin_inventory(
|
||||||
|
itemid SERIAL PRIMARY KEY,
|
||||||
|
userid BIGINT NOT NULL REFERENCES user_config (userid) ON DELETE CASCADE,
|
||||||
|
custom_skin_id INTEGER NOT NULL REFERENCES customised_skins (custom_skin_id) ON DELETE CASCADE,
|
||||||
|
transactionid INTEGER REFERENCES gem_transactions (transactionid),
|
||||||
|
active BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
acquired_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
expires_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
CREATE INDEX user_skin_inventory_users ON user_skin_inventory(userid);
|
||||||
|
CREATE UNIQUE INDEX user_skin_inventory_active ON user_skin_inventory(userid) WHERE active = TRUE;
|
||||||
|
|
||||||
|
CREATE VIEW user_active_skins AS
|
||||||
|
SELECT
|
||||||
|
*
|
||||||
|
FROM user_skin_inventory
|
||||||
|
WHERE active=True;
|
||||||
|
-- }}}
|
||||||
|
|
||||||
|
|
||||||
|
-- Premium Guild Data {{{
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Contributions members have made to guild premium funds
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
-- }}}
|
||||||
|
|
||||||
|
|
||||||
-- vim: set fdm=marker:
|
-- vim: set fdm=marker:
|
||||||
|
|||||||
Reference in New Issue
Block a user