diff --git a/bot/constants.py b/bot/constants.py index 7038953b..ffe4b057 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -1,2 +1,2 @@ CONFIG_FILE = "config/bot.conf" -DATA_VERSION = 11 +DATA_VERSION = 12 diff --git a/bot/core/__init__.py b/bot/core/__init__.py index be157adb..9be4f2bd 100644 --- a/bot/core/__init__.py +++ b/bot/core/__init__.py @@ -1,7 +1,5 @@ from . import data # noqa -from . import patches - from .module import module from .lion import Lion from . import blacklists diff --git a/bot/core/data.py b/bot/core/data.py index 71e45b52..a74c754c 100644 --- a/bot/core/data.py +++ b/bot/core/data.py @@ -17,7 +17,7 @@ app_config = Table('AppConfig') user_config = RowTable( 'user_config', - ('userid', 'timezone', 'topgg_vote_reminder', 'avatar_hash'), + ('userid', 'timezone', 'topgg_vote_reminder', 'avatar_hash', 'gems'), 'userid', cache=TTLCache(5000, ttl=60*5) ) diff --git a/bot/core/patches.py b/bot/core/patches.py deleted file mode 100644 index a292c443..00000000 --- a/bot/core/patches.py +++ /dev/null @@ -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 diff --git a/bot/data/interfaces.py b/bot/data/interfaces.py index 501acdc0..1df7ff56 100644 --- a/bot/data/interfaces.py +++ b/bot/data/interfaces.py @@ -95,6 +95,7 @@ class Table: Decorator to add a saved query to the table. """ self.queries[func.__name__] = func + return func class Row: @@ -149,7 +150,7 @@ class Row: self._pending = None 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: raise ValueError("Refreshing a {} which no longer exists!".format(type(self).__name__)) self.data = row diff --git a/bot/meta/__init__.py b/bot/meta/__init__.py index eab9c7b8..b8dfa3c0 100644 --- a/bot/meta/__init__.py +++ b/bot/meta/__init__.py @@ -1,4 +1,8 @@ from .logger import log, logger + +from . import interactions +from . import patches + from .client import client from .config import conf from .args import args diff --git a/bot/meta/client.py b/bot/meta/client.py index a3f02877..0ea0d4f1 100644 --- a/bot/meta/client.py +++ b/bot/meta/client.py @@ -1,10 +1,13 @@ from discord import Intents from cmdClient.cmdClient import cmdClient +from . import patches +from .interactions import InteractionType from .config import conf from .sharding import shard_number, shard_count from LionContext import LionContext + # Initialise client owners = [int(owner) for owner in conf.bot.getlist('owners')] intents = Intents.all() @@ -18,3 +21,14 @@ client = cmdClient( baseContext=LionContext ) 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() diff --git a/bot/meta/config.py b/bot/meta/config.py index 819bdf42..c6ba57f1 100644 --- a/bot/meta/config.py +++ b/bot/meta/config.py @@ -78,10 +78,6 @@ class Conf: self.default = self.config["DEFAULT"] self.section = MapDotProxy(self.config[self.section_name]) 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. 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] 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 conf = self diff --git a/bot/meta/interactions/__init__.py b/bot/meta/interactions/__init__.py new file mode 100644 index 00000000..b522979a --- /dev/null +++ b/bot/meta/interactions/__init__.py @@ -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 diff --git a/bot/meta/interactions/components.py b/bot/meta/interactions/components.py new file mode 100644 index 00000000..087e3cde --- /dev/null +++ b/bot/meta/interactions/components.py @@ -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 diff --git a/bot/meta/interactions/enums.py b/bot/meta/interactions/enums.py new file mode 100644 index 00000000..3d1c8c6c --- /dev/null +++ b/bot/meta/interactions/enums.py @@ -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 + diff --git a/bot/meta/interactions/interactions.py b/bot/meta/interactions/interactions.py new file mode 100644 index 00000000..3277e48d --- /dev/null +++ b/bot/meta/interactions/interactions.py @@ -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 diff --git a/bot/meta/interactions/manager.py b/bot/meta/interactions/manager.py new file mode 100644 index 00000000..1511ad51 --- /dev/null +++ b/bot/meta/interactions/manager.py @@ -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 diff --git a/bot/meta/interactions/modals.py b/bot/meta/interactions/modals.py new file mode 100644 index 00000000..56bcfd8e --- /dev/null +++ b/bot/meta/interactions/modals.py @@ -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 diff --git a/bot/meta/patches.py b/bot/meta/patches.py new file mode 100644 index 00000000..621004d9 --- /dev/null +++ b/bot/meta/patches.py @@ -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 diff --git a/bot/modules/accountability/TimeSlot.py b/bot/modules/accountability/TimeSlot.py index 43f3d664..dcb87e14 100644 --- a/bot/modules/accountability/TimeSlot.py +++ b/bot/modules/accountability/TimeSlot.py @@ -2,6 +2,7 @@ from typing import List, Dict import datetime import discord import asyncio +import random from settings import GuildSettings from utils.lib import tick, cross @@ -364,6 +365,8 @@ class TimeSlot: Start the accountability room slot. Update the status message, and launch the DM reminder. """ + dither = 15 * random.random() + await asyncio.sleep(dither) if self.channel: try: 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. Unloads the TimeSlot from cache. """ + dither = 15 * random.random() + await asyncio.sleep(dither) if self.channel: try: await self.channel.delete() diff --git a/bot/modules/meta/help.py b/bot/modules/meta/help.py index 719a87df..4835636d 100644 --- a/bot/modules/meta/help.py +++ b/bot/modules/meta/help.py @@ -21,26 +21,27 @@ group_hints = { 'Personal Settings': "*Tell me about yourself!*", 'Guild Admin': "*Dangerous administration commands!*", '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 = ( - ('Pomodoro', 'Productivity', 'Statistics', 'Economy', 'Personal Settings', 'Meta'), + ('Pomodoro', 'Productivity', 'Support Us', 'Statistics', 'Economy', 'Personal Settings', 'Meta'), ) mod_group_order = ( ('Moderation', 'Meta'), - ('Pomodoro', 'Productivity', 'Statistics', 'Economy', 'Personal Settings') + ('Pomodoro', 'Productivity', 'Support Us', 'Statistics', 'Economy', 'Personal Settings') ) admin_group_order = ( ('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', 'Guild Admin', 'Guild Configuration', 'Moderation', 'Meta'), - ('Pomodoro', 'Productivity', 'Statistics', 'Economy', 'Personal Settings') + ('Pomodoro', 'Productivity', 'Support Us', 'Statistics', 'Economy', 'Personal Settings') ) # Help embed format diff --git a/bot/modules/sponsors/module.py b/bot/modules/sponsors/module.py index ae9ba299..232eafa6 100644 --- a/bot/modules/sponsors/module.py +++ b/bot/modules/sponsors/module.py @@ -16,7 +16,13 @@ sponsored_commands = {'profile', 'stats', 'weekly', 'monthly'} async def sponsor_reply_wrapper(func, ctx: LionContext, *args, **kwargs): if ctx.cmd and ctx.cmd.name in sponsored_commands: 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( description=prompt, colour=discord.Colour.dark_theme() diff --git a/bot/modules/study/timers/Timer.py b/bot/modules/study/timers/Timer.py index df603aaa..20a60c70 100644 --- a/bot/modules/study/timers/Timer.py +++ b/bot/modules/study/timers/Timer.py @@ -170,6 +170,7 @@ class Timer: self.last_seen[member.id] = utc_now() content = [] + if to_kick: # Do kick await asyncio.gather( @@ -194,9 +195,12 @@ class Timer: old_reaction_message = self.reaction_message # 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( content='\n'.join(content), - **(await self.status()) + **args ) await self.reaction_message.add_reaction('✅') @@ -386,7 +390,7 @@ class Timer: if self._state.end < utc_now(): 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_last_status()) diff --git a/bot/modules/todo/Tasklist.py b/bot/modules/todo/Tasklist.py index 74df8d64..92195fde 100644 --- a/bot/modules/todo/Tasklist.py +++ b/bot/modules/todo/Tasklist.py @@ -4,7 +4,7 @@ import discord import asyncio from cmdClient.lib import SafeCancellation -from meta import client +from meta import client, conf from core import Lion from data import NULL, NOTNULL from settings import GuildSettings @@ -26,11 +26,11 @@ class Tasklist: checkmark = "✔" block_size = 15 - next_emoji = "▶" - prev_emoji = "◀" - question_emoji = "❔" - cancel_emoji = "❌" - refresh_emoji = "🔄" + next_emoji = conf.emojis.forward + prev_emoji = conf.emojis.backward + question_emoji = conf.emojis.question + cancel_emoji = conf.emojis.cancel + refresh_emoji = conf.emojis.refresh paged_reaction_order = ( prev_emoji, cancel_emoji, question_emoji, refresh_emoji, next_emoji @@ -121,7 +121,7 @@ class Tasklist: self.active[(member.id, channel.id)] = self @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) return tasklist if tasklist is not None else cls(member, channel) @@ -574,7 +574,7 @@ class Tasklist: """ 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: # Attempt to remove reaction try: diff --git a/bot/modules/todo/commands.py b/bot/modules/todo/commands.py index 9932c6d4..78ceba5c 100644 --- a/bot/modules/todo/commands.py +++ b/bot/modules/todo/commands.py @@ -11,7 +11,7 @@ from .Tasklist import Tasklist name="todo", desc="Display and edit your personal To-Do list.", group="Productivity", - flags=('add==', 'delete==', 'check==', 'uncheck==', 'edit==') + flags=('add==', 'delete==', 'check==', 'uncheck==', 'edit==', 'text') ) @in_guild() async def cmd_todo(ctx, flags): @@ -69,7 +69,7 @@ async def cmd_todo(ctx, flags): return # 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 = { 'add': (('add', ), tasklist.parse_add), diff --git a/bot/modules/topgg/module.py b/bot/modules/topgg/module.py index a3872192..3632e9f1 100644 --- a/bot/modules/topgg/module.py +++ b/bot/modules/topgg/module.py @@ -47,6 +47,8 @@ async def topgg_reply_wrapper(func, ctx: LionContext, *args, suggest_vote=True, pass elif ctx.guild and ctx.guild.id in ctx.client.settings.topgg_guild_whitelist.value: 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): upvote_info_formatted = upvote_info.format(lion_yayemote, ctx.best_prefix, lion_loveemote) diff --git a/config/emojis.conf b/config/emojis.conf new file mode 100644 index 00000000..b2688044 --- /dev/null +++ b/config/emojis.conf @@ -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> diff --git a/config/example-bot.conf b/config/example-bot.conf index d0fed43d..dfef772e 100644 --- a/config/example-bot.conf +++ b/config/example-bot.conf @@ -3,6 +3,7 @@ log_file = bot.log log_channel = error_channel = guild_log_channel = +gem_transaction_channel = prefix = ! token = @@ -22,33 +23,4 @@ topgg_port = invite_link = https://discord.studylions.com/invite support_link = https://discord.gg/StudyLions -[EMOJIS] -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 = +ALSO_READ = config/emojis.conf diff --git a/data/migration/v11-v12/migration.sql b/data/migration/v11-v12/migration.sql new file mode 100644 index 00000000..0f5c0abd --- /dev/null +++ b/data/migration/v11-v12/migration.sql @@ -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'); diff --git a/data/schema.sql b/data/schema.sql index 69592305..67dcd197 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -4,7 +4,7 @@ CREATE TABLE VersionHistory( time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, 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() @@ -48,9 +48,10 @@ CREATE TABLE global_guild_blacklist( CREATE TABLE user_config( userid BIGINT PRIMARY KEY, timezone TEXT, - topgg_vote_reminder, + topgg_vote_reminder BOOLEAN, 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: