From df032a8f7869e9bbba4ad7f7efbc2afa00fc0b39 Mon Sep 17 00:00:00 2001 From: JetRaidz Date: Sat, 19 Feb 2022 21:00:08 +1300 Subject: [PATCH 01/37] (data): Adding support for premium currency. --- bot/core/data.py | 15 ++++++++++++++- data/schema.sql | 5 +++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/bot/core/data.py b/bot/core/data.py index 58c1331b..53066580 100644 --- a/bot/core/data.py +++ b/bot/core/data.py @@ -14,7 +14,7 @@ meta = RowTable( 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) ) @@ -120,6 +120,19 @@ def get_member_rank(guildid, userid, untracked): return curs.fetchone() or (None, None) +@user_config.save_query +def set_gems(userid, amount): + with user_config.conn as conn: + cursor = conn.cursor() + cursor.execute( + "UPDATE user_config SET gems = %s WHERE userid = %s RETURNING *", + (amount, userid) + ) + data = cursor.fetchone() + if data: + return user_config._make_rows(data)[0] + + global_guild_blacklist = Table('global_guild_blacklist') global_user_blacklist = Table('global_user_blacklist') ignored_members = Table('ignored_members') diff --git a/data/schema.sql b/data/schema.sql index 5287648e..640a3c39 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -42,9 +42,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 ); -- }}} From 5ecb10be43aa8653b5d4a5bd41ca85bda8fa10f6 Mon Sep 17 00:00:00 2001 From: JetRaidz Date: Sun, 6 Mar 2022 00:18:49 +1300 Subject: [PATCH 02/37] Add premium group in help command. --- bot/modules/meta/help.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/bot/modules/meta/help.py b/bot/modules/meta/help.py index 1329fc02..c9d02b0a 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!*", + 'Premium': "*Support the team and keep the project alive by using LionGems!*" } standard_group_order = ( - ('Pomodoro', 'Productivity', 'Statistics', 'Economy', 'Personal Settings', 'Meta'), + ('Pomodoro', 'Productivity', 'Premium', 'Statistics', 'Economy', 'Personal Settings', 'Meta'), ) mod_group_order = ( ('Moderation', 'Meta'), - ('Pomodoro', 'Productivity', 'Statistics', 'Economy', 'Personal Settings') + ('Pomodoro', 'Productivity', 'Premium', 'Statistics', 'Economy', 'Personal Settings') ) admin_group_order = ( ('Guild Admin', 'Guild Configuration', 'Moderation', 'Meta'), - ('Pomodoro', 'Productivity', 'Statistics', 'Economy', 'Personal Settings') + ('Pomodoro', 'Productivity', 'Premium', 'Statistics', 'Economy', 'Personal Settings', 'LionGems') ) bot_admin_group_order = ( ('Bot Admin', 'Guild Admin', 'Guild Configuration', 'Moderation', 'Meta'), - ('Pomodoro', 'Productivity', 'Statistics', 'Economy', 'Personal Settings') + ('Pomodoro', 'Productivity', 'Premium', 'Statistics', 'Economy', 'Personal Settings', 'LionGems') ) # Help embed format From 61f22b3d4f51e2266badf6a799df391a21a08c11 Mon Sep 17 00:00:00 2001 From: JetRaidz Date: Sun, 6 Mar 2022 00:20:38 +1300 Subject: [PATCH 03/37] Remove old group names --- bot/modules/meta/help.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/modules/meta/help.py b/bot/modules/meta/help.py index c9d02b0a..ff797cc4 100644 --- a/bot/modules/meta/help.py +++ b/bot/modules/meta/help.py @@ -36,12 +36,12 @@ mod_group_order = ( admin_group_order = ( ('Guild Admin', 'Guild Configuration', 'Moderation', 'Meta'), - ('Pomodoro', 'Productivity', 'Premium', 'Statistics', 'Economy', 'Personal Settings', 'LionGems') + ('Pomodoro', 'Productivity', 'Premium', 'Statistics', 'Economy', 'Personal Settings') ) bot_admin_group_order = ( ('Bot Admin', 'Guild Admin', 'Guild Configuration', 'Moderation', 'Meta'), - ('Pomodoro', 'Productivity', 'Premium', 'Statistics', 'Economy', 'Personal Settings', 'LionGems') + ('Pomodoro', 'Productivity', 'Premium', 'Statistics', 'Economy', 'Personal Settings') ) # Help embed format From 08eb9e8890f42eada461ec9e09dfa064bf7e3dca Mon Sep 17 00:00:00 2001 From: JetRaidz Date: Wed, 23 Mar 2022 21:02:01 +1300 Subject: [PATCH 04/37] (data): Add LionGem audit log database table. --- data/schema.sql | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/data/schema.sql b/data/schema.sql index 640a3c39..330b078c 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -802,4 +802,18 @@ create TABLE topgg( CREATE INDEX topgg_userid_timestamp ON topgg (userid, boostedTimestamp); -- }}} +-- LionGem audit log {{{ +CREATE TABLE gem_transactions( + transactionid SERIAL PRIMARY KEY, + actorid BIGINT NOT NULL, + targetid BIGINT NOT NULL, + amount INTEGER NOT NULL, + total INTEGER NOT NULL, + reason TEXT NOT NULL, + note TEXT, + gift BOOLEAN DEFAULT FALSE, + _timestamp TIMESTAMPTZ DEFAULT now() +); +-- }}} + -- vim: set fdm=marker: From 6f9c8b7138a981de0569635a63a8c21067298c88 Mon Sep 17 00:00:00 2001 From: Conatum Date: Sat, 2 Apr 2022 11:40:42 +0300 Subject: [PATCH 05/37] gems: Generalise gem transaction table. Updates gem audit table for more generality. Fixes issue with `Row._refresh()`. Data migration v11 -> v12. Remove `set_gems` (not needed due to gem module refactor). --- bot/constants.py | 2 +- bot/core/data.py | 13 ------------- bot/data/interfaces.py | 2 +- data/migration/v11-v12/migration.sql | 27 +++++++++++++++++++++++++++ data/schema.sql | 19 ++++++++++++++----- 5 files changed, 43 insertions(+), 20 deletions(-) create mode 100644 data/migration/v11-v12/migration.sql 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/data.py b/bot/core/data.py index b2747a7e..a74c754c 100644 --- a/bot/core/data.py +++ b/bot/core/data.py @@ -123,19 +123,6 @@ def get_member_rank(guildid, userid, untracked): return curs.fetchone() or (None, None) -@user_config.save_query -def set_gems(userid, amount): - with user_config.conn as conn: - cursor = conn.cursor() - cursor.execute( - "UPDATE user_config SET gems = %s WHERE userid = %s RETURNING *", - (amount, userid) - ) - data = cursor.fetchone() - if data: - return user_config._make_rows(data)[0] - - global_guild_blacklist = Table('global_guild_blacklist') global_user_blacklist = Table('global_user_blacklist') ignored_members = Table('ignored_members') diff --git a/bot/data/interfaces.py b/bot/data/interfaces.py index 501acdc0..44cc4a3c 100644 --- a/bot/data/interfaces.py +++ b/bot/data/interfaces.py @@ -149,7 +149,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/data/migration/v11-v12/migration.sql b/data/migration/v11-v12/migration.sql new file mode 100644 index 00000000..460f037b --- /dev/null +++ b/data/migration/v11-v12/migration.sql @@ -0,0 +1,27 @@ +-- 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); +-- }}} + +INSERT INTO VersionHistory (version, author) VALUES (12, 'v11-v12 migration'); diff --git a/data/schema.sql b/data/schema.sql index dafd6291..cbaebd3e 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() @@ -823,17 +823,26 @@ 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, - targetid BIGINT NOT NULL, + from_account BIGINT, + to_account BIGINT, amount INTEGER NOT NULL, - total INTEGER NOT NULL, - reason TEXT NOT NULL, + description TEXT NOT NULL, note TEXT, - gift BOOLEAN DEFAULT FALSE, + reference TEXT, _timestamp TIMESTAMPTZ DEFAULT now() ); +CREATE INDEX gem_transactions_from ON gem_transactions (from_account); -- }}} -- vim: set fdm=marker: From cc7c988007068a4f85f29290dc0a1c3d5ed73046 Mon Sep 17 00:00:00 2001 From: Conatum Date: Mon, 18 Apr 2022 12:53:17 +0300 Subject: [PATCH 06/37] feature (interactions): Basic button support. --- bot/core/__init__.py | 2 - bot/meta/__init__.py | 4 + bot/meta/client.py | 1 + bot/meta/interactions/__init__.py | 3 + bot/meta/interactions/components.py | 125 ++++++++++++++++++++++++++ bot/meta/interactions/enums.py | 28 ++++++ bot/meta/interactions/interactions.py | 52 +++++++++++ bot/{core => meta}/patches.py | 91 +++++++++++++++++-- 8 files changed, 299 insertions(+), 7 deletions(-) create mode 100644 bot/meta/interactions/__init__.py create mode 100644 bot/meta/interactions/components.py create mode 100644 bot/meta/interactions/enums.py create mode 100644 bot/meta/interactions/interactions.py rename bot/{core => meta}/patches.py (57%) 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/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..267ffe28 100644 --- a/bot/meta/client.py +++ b/bot/meta/client.py @@ -1,6 +1,7 @@ from discord import Intents from cmdClient.cmdClient import cmdClient +from . import patches from .config import conf from .sharding import shard_number, shard_count from LionContext import LionContext diff --git a/bot/meta/interactions/__init__.py b/bot/meta/interactions/__init__.py new file mode 100644 index 00000000..e7e68854 --- /dev/null +++ b/bot/meta/interactions/__init__.py @@ -0,0 +1,3 @@ +from . import enums +from .interactions import _component_interaction_factory, Interaction, ComponentInteraction +from .components import * diff --git a/bot/meta/interactions/components.py b/bot/meta/interactions/components.py new file mode 100644 index 00000000..a09d7414 --- /dev/null +++ b/bot/meta/interactions/components.py @@ -0,0 +1,125 @@ +import asyncio +import uuid + +from .enums import ButtonStyle, InteractionType + + +""" +Notes: + When interaction is sent, add message info + Add wait_for to Button and SelectMenu + wait_for_interaction for generic + listen=True for the listenables, register with a listener + Need a deregister then as well + + send(..., components=[ActionRow(Button(...))]) + + Automatically ack interaction? DEFERRED_UPDATE_MESSAGE + + async def Button.wait_for(timeout=None, ack=False) + Blocks until the button is pressed. Returns a ButtonPress (Interaction). + def MessageComponent.add_callback(timeout) + Adds an async callback function to the Component. + + Construct the response independent of the original component. + Original component has a convenience wait_for that runs wait_for_interaction(custom_id=self.custom_id)... + The callback? Just add a wait_for +""" + + +class MessageComponent: + _type = None + + def __init_(self, *args, **kwargs): + self.message = None + + def listen(self): + ... + + def close(self): + ... + + +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 Button(MessageComponent): + _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 = 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() + return data + + async def wait_for_press(self, timeout=None, check=None): + from meta import client + + def _check(interaction): + valid = True + print(interaction.custom_id) + valid = valid and interaction.interaction_type == InteractionType.MESSAGE_COMPONENT + 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 on_press(self, timeout=None, repeat=True, pass_args=(), pass_kwargs={}): + def wrapper(func): + async def wrapped(): + while True: + try: + button_press = await self.wait_for_press(timeout=timeout) + 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 SelectMenu(MessageComponent): + _type = 3 + + +# MessageComponent listener +live_components = {} diff --git a/bot/meta/interactions/enums.py b/bot/meta/interactions/enums.py new file mode 100644 index 00000000..c08974be --- /dev/null +++ b/bot/meta/interactions/enums.py @@ -0,0 +1,28 @@ +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 InteractionCallback(IntEnum): + DEFERRED_UPDATE_MESSAGE = 6 diff --git a/bot/meta/interactions/interactions.py b/bot/meta/interactions/interactions.py new file mode 100644 index 00000000..661189a9 --- /dev/null +++ b/bot/meta/interactions/interactions.py @@ -0,0 +1,52 @@ +import asyncio +from .enums import ComponentType, InteractionType, InteractionCallback + + +class Interaction: + __slots__ = ( + 'id', + 'token', + '_state' + ) + + async def callback_deferred(self): + return await self._state.http.interaction_callback(self.id, self.token, InteractionCallback.DEFERRED_UPDATE_MESSAGE) + + def ack(self): + asyncio.create_task(self.callback_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__ = () + + +def _component_interaction_factory(data): + component_type = data['data']['component_type'] + + if component_type == ComponentType.BUTTON: + return ButtonPress + else: + return None diff --git a/bot/core/patches.py b/bot/meta/patches.py similarity index 57% rename from bot/core/patches.py rename to bot/meta/patches.py index a292c443..14a5741e 100644 --- a/bot/core/patches.py +++ b/bot/meta/patches.py @@ -1,14 +1,23 @@ """ Temporary patches for the discord.py library to support new features of the discord API. """ +import logging + +from discord.state import ConnectionState from discord.http import Route, HTTPClient from discord.abc import Messageable -from discord.utils import InvalidArgument -from discord import File, AllowedMentions +from discord.utils import InvalidArgument, _get_as_snowflake +from discord import File, AllowedMentions, Member, User, Message + +from .interactions import _component_interaction_factory +from .interactions.enums import InteractionType + + +log = logging.getLogger(__name__) def send_message(self, channel_id, content, *, tts=False, embeds=None, - nonce=None, allowed_mentions=None, message_reference=None): + nonce=None, allowed_mentions=None, message_reference=None, components=None): r = Route('POST', '/channels/{channel_id}/messages', channel_id=channel_id) payload = {} @@ -30,16 +39,37 @@ def send_message(self, channel_id, content, *, tts=False, embeds=None, if message_reference: payload['message_reference'] = message_reference + if components is not None: + payload['components'] = components + + return self.request(r, json=payload) + + +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) HTTPClient.send_message = send_message +HTTPClient.interaction_callback = interaction_callback 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): + mention_author=None, components=None): channel = await self._get_channel() state = self._state @@ -53,6 +83,9 @@ async def send(self, content=None, *, tts=False, embed=None, embeds=None, file=N 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() @@ -101,7 +134,7 @@ async def send(self, content=None, *, tts=False, embed=None, embeds=None, file=N else: data = await state.http.send_message(channel.id, content, tts=tts, embeds=embeds, nonce=nonce, allowed_mentions=allowed_mentions, - message_reference=reference) + message_reference=reference, components=components) ret = state.create_message(channel=channel, data=data) if delete_after is not None: @@ -109,3 +142,51 @@ async def send(self, content=None, *, tts=False, embed=None, embeds=None, file=N 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) + + 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) + + 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'] + ) + else: + log.debug('INTERACTION_CREATE recieved unhandled interaction type: %s', data['type']) + interaction = None + + if interaction: + self.dispatch('interaction_create', interaction) + + +ConnectionState.parse_interaction_create = parse_interaction_create From e73302d21fa8bad41793c6413bdedf5705efb712 Mon Sep 17 00:00:00 2001 From: Conatum Date: Mon, 18 Apr 2022 13:40:38 +0300 Subject: [PATCH 07/37] (interactions): Basic select menu support. --- bot/meta/interactions/components.py | 131 ++++++++++++++------------ bot/meta/interactions/interactions.py | 10 ++ 2 files changed, 83 insertions(+), 58 deletions(-) diff --git a/bot/meta/interactions/components.py b/bot/meta/interactions/components.py index a09d7414..a651e241 100644 --- a/bot/meta/interactions/components.py +++ b/bot/meta/interactions/components.py @@ -4,41 +4,12 @@ import uuid from .enums import ButtonStyle, InteractionType -""" -Notes: - When interaction is sent, add message info - Add wait_for to Button and SelectMenu - wait_for_interaction for generic - listen=True for the listenables, register with a listener - Need a deregister then as well - - send(..., components=[ActionRow(Button(...))]) - - Automatically ack interaction? DEFERRED_UPDATE_MESSAGE - - async def Button.wait_for(timeout=None, ack=False) - Blocks until the button is pressed. Returns a ButtonPress (Interaction). - def MessageComponent.add_callback(timeout) - Adds an async callback function to the Component. - - Construct the response independent of the original component. - Original component has a convenience wait_for that runs wait_for_interaction(custom_id=self.custom_id)... - The callback? Just add a wait_for -""" - - class MessageComponent: _type = None def __init_(self, *args, **kwargs): self.message = None - def listen(self): - ... - - def close(self): - ... - class ActionRow(MessageComponent): _type = 1 @@ -54,7 +25,37 @@ class ActionRow(MessageComponent): return data -class Button(MessageComponent): +class AwaitableComponent: + async def wait_for(self, timeout=None, check=None): + from meta import client + + def _check(interaction): + valid = True + print(interaction.custom_id) + valid = valid and interaction.interaction_type == InteractionType.MESSAGE_COMPONENT + 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, pass_args=(), pass_kwargs={}): + def wrapper(func): + async def wrapped(): + while True: + try: + button_press = await self.wait_for(timeout=timeout) + 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): @@ -63,7 +64,7 @@ class Button(MessageComponent): raise ValueError("Link buttons must have a url") custom_id = None elif custom_id is None: - custom_id = uuid.uuid4() + custom_id = str(uuid.uuid4()) self.label = label self.style = style @@ -88,38 +89,52 @@ class Button(MessageComponent): data['emoji'] = self.emoji.to_dict() return data - async def wait_for_press(self, timeout=None, check=None): - from meta import client - def _check(interaction): - valid = True - print(interaction.custom_id) - valid = valid and interaction.interaction_type == InteractionType.MESSAGE_COMPONENT - valid = valid and interaction.custom_id == self.custom_id - valid = valid and (check is None or check(interaction)) - return valid +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 - return await client.wait_for('interaction_create', timeout=timeout, check=_check) + 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 - def on_press(self, timeout=None, repeat=True, pass_args=(), pass_kwargs={}): - def wrapper(func): - async def wrapped(): - while True: - try: - button_press = await self.wait_for_press(timeout=timeout) - 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 + return data -class SelectMenu(MessageComponent): +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 -# MessageComponent listener -live_components = {} + 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/interactions.py b/bot/meta/interactions/interactions.py index 661189a9..01f5d993 100644 --- a/bot/meta/interactions/interactions.py +++ b/bot/meta/interactions/interactions.py @@ -43,10 +43,20 @@ class ButtonPress(ComponentInteraction): __slots__ = () +class Selection(ComponentInteraction): + __slots__ = ('values',) + + def _from_data(self, data): + super()._from_data(data) + self.values = data['data']['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 From 035a2959625a18434e278f9cfa1640818ebfc07f Mon Sep 17 00:00:00 2001 From: Conatum Date: Tue, 19 Apr 2022 11:34:34 +0300 Subject: [PATCH 08/37] (interactions): Basic support for modals. --- bot/meta/interactions/__init__.py | 3 +- bot/meta/interactions/components.py | 12 +++- bot/meta/interactions/enums.py | 12 ++++ bot/meta/interactions/interactions.py | 56 +++++++++++++++++- bot/meta/interactions/modals.py | 54 ++++++++++++++++++ bot/meta/patches.py | 81 +++++++++++++++++++++++++-- 6 files changed, 207 insertions(+), 11 deletions(-) create mode 100644 bot/meta/interactions/modals.py diff --git a/bot/meta/interactions/__init__.py b/bot/meta/interactions/__init__.py index e7e68854..660b5a93 100644 --- a/bot/meta/interactions/__init__.py +++ b/bot/meta/interactions/__init__.py @@ -1,3 +1,4 @@ from . import enums -from .interactions import _component_interaction_factory, Interaction, ComponentInteraction +from .interactions import _component_interaction_factory, Interaction, ComponentInteraction, ModalResponse from .components import * +from .modals import * diff --git a/bot/meta/interactions/components.py b/bot/meta/interactions/components.py index a651e241..c7788546 100644 --- a/bot/meta/interactions/components.py +++ b/bot/meta/interactions/components.py @@ -1,15 +1,23 @@ 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 @@ -26,13 +34,15 @@ class ActionRow(MessageComponent): class AwaitableComponent: + interaction_type: InteractionType = None + async def wait_for(self, timeout=None, check=None): from meta import client def _check(interaction): valid = True print(interaction.custom_id) - valid = valid and interaction.interaction_type == InteractionType.MESSAGE_COMPONENT + 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 diff --git a/bot/meta/interactions/enums.py b/bot/meta/interactions/enums.py index c08974be..3d1c8c6c 100644 --- a/bot/meta/interactions/enums.py +++ b/bot/meta/interactions/enums.py @@ -24,5 +24,17 @@ class ButtonStyle(IntEnum): 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 index 01f5d993..f51df5ed 100644 --- a/bot/meta/interactions/interactions.py +++ b/bot/meta/interactions/interactions.py @@ -9,11 +9,23 @@ class Interaction: '_state' ) - async def callback_deferred(self): - return await self._state.http.interaction_callback(self.id, self.token, InteractionCallback.DEFERRED_UPDATE_MESSAGE) + 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.callback_deferred()) + asyncio.create_task(self.response_deferred()) class ComponentInteraction(Interaction): @@ -51,6 +63,44 @@ class Selection(ComponentInteraction): self.values = data['data']['values'] +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'] 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 index 14a5741e..f692c4da 100644 --- a/bot/meta/patches.py +++ b/bot/meta/patches.py @@ -3,19 +3,29 @@ Temporary patches for the discord.py library to support new features of the disc """ 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 +from discord.utils import InvalidArgument, _get_as_snowflake, to_json from discord import File, AllowedMentions, Member, User, Message -from .interactions import _component_interaction_factory +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) @@ -39,12 +49,59 @@ def send_message(self, channel_id, content, *, tts=False, embeds=None, if message_reference: payload['message_reference'] = message_reference - if components is not None: + 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', @@ -62,7 +119,16 @@ def interaction_callback(self, interaction_id, interaction_token, callback_type, 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 @@ -114,7 +180,7 @@ async def send(self, content=None, *, tts=False, embed=None, embeds=None, file=N 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) + message_reference=reference, components=components) finally: file.close() @@ -126,8 +192,8 @@ async def send(self, content=None, *, tts=False, embed=None, embeds=None, file=N 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) + embeds=embeds, nonce=nonce, allowed_mentions=allowed_mentions, + message_reference=reference, components=components) finally: for f in files: f.close() @@ -181,8 +247,11 @@ def parse_interaction_create(self, data): '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: From 413b54c5ab22b0d6ea0d8ef2a219fe371022d88f Mon Sep 17 00:00:00 2001 From: Conatum Date: Tue, 19 Apr 2022 13:46:44 +0300 Subject: [PATCH 09/37] (interactions): Support message response. --- bot/meta/interactions/components.py | 1 - bot/meta/interactions/interactions.py | 21 +++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/bot/meta/interactions/components.py b/bot/meta/interactions/components.py index c7788546..81f21145 100644 --- a/bot/meta/interactions/components.py +++ b/bot/meta/interactions/components.py @@ -41,7 +41,6 @@ class AwaitableComponent: def _check(interaction): valid = True - print(interaction.custom_id) 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)) diff --git a/bot/meta/interactions/interactions.py b/bot/meta/interactions/interactions.py index f51df5ed..c1ec18e9 100644 --- a/bot/meta/interactions/interactions.py +++ b/bot/meta/interactions/interactions.py @@ -9,6 +9,27 @@ class Interaction: '_state' ) + async def response(self, content=None, embeds=None, components=None, ephemeral=None): + data = {} + if content is not None: + data['content'] = str(content) + + 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_deferred(self): return await self._state.http.interaction_callback( self.id, From 041fb18415f90006c4ccead8bfc0e3e9f2c315ea Mon Sep 17 00:00:00 2001 From: JetRaidz Date: Tue, 19 Apr 2022 23:36:44 +1200 Subject: [PATCH 10/37] Import skins module. --- bot/modules/plugins/__init__.py | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 bot/modules/plugins/__init__.py diff --git a/bot/modules/plugins/__init__.py b/bot/modules/plugins/__init__.py new file mode 100644 index 00000000..1bf505c5 --- /dev/null +++ b/bot/modules/plugins/__init__.py @@ -0,0 +1,3 @@ +from . import gui +from . import liongems +from . import skins From 90714d85e5284e02bdad8432623fcc23f7a34b15 Mon Sep 17 00:00:00 2001 From: JetRaidz Date: Tue, 19 Apr 2022 23:46:15 +1200 Subject: [PATCH 11/37] Revert "Import skins module." This reverts commit 041fb18415f90006c4ccead8bfc0e3e9f2c315ea. --- bot/modules/plugins/__init__.py | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 bot/modules/plugins/__init__.py diff --git a/bot/modules/plugins/__init__.py b/bot/modules/plugins/__init__.py deleted file mode 100644 index 1bf505c5..00000000 --- a/bot/modules/plugins/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from . import gui -from . import liongems -from . import skins From 5eef17329d8f5c06e33f1bcc3a1d08ae65636efc Mon Sep 17 00:00:00 2001 From: Conatum Date: Mon, 25 Apr 2022 22:35:04 +0300 Subject: [PATCH 12/37] data (skins): Add skin data definitions. --- data/schema.sql | 76 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/data/schema.sql b/data/schema.sql index cbaebd3e..3dcf1401 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -845,4 +845,80 @@ CREATE TABLE gem_transactions( 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, + properties.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, + premium_until TIMESTAMPTZ NOT NULL, + custom_skin_id INTEGER REFERENCES customised_skins +); + +-- Contributions members have made to guild premium funds +CREATE TABLE premium_guild_contributions( +); +-- }}} + + -- vim: set fdm=marker: From a5d23b515345ff12491c7cd29f1658d928ac8e36 Mon Sep 17 00:00:00 2001 From: Conatum Date: Mon, 25 Apr 2022 22:35:49 +0300 Subject: [PATCH 13/37] fix (interactions): Allow empty `message`. In some circumstances, interactions may be returned with no `message`. Fixes a bug causing this to crash the entire event loop. --- bot/meta/patches.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/bot/meta/patches.py b/bot/meta/patches.py index f692c4da..8f25c39c 100644 --- a/bot/meta/patches.py +++ b/bot/meta/patches.py @@ -229,13 +229,16 @@ def parse_interaction_create(self, data): # Assume user user = self.get_user(_get_as_snowflake(data['user'], 'id')) or User(data=data['user'], state=self) - 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) + 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: From 60e59246abe70d85bb2b4dd2a27a3192cdb5d481 Mon Sep 17 00:00:00 2001 From: Conatum Date: Tue, 26 Apr 2022 03:24:38 +0300 Subject: [PATCH 14/37] fix (Table): Return method after `save_query`. --- bot/data/interfaces.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/data/interfaces.py b/bot/data/interfaces.py index 44cc4a3c..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: From 3cac3f8248f0d6860e9d5d429ab6fd60e5637f92 Mon Sep 17 00:00:00 2001 From: Conatum Date: Tue, 26 Apr 2022 13:43:22 +0300 Subject: [PATCH 15/37] fix (data): Fix typo in `customised_skin_data`. --- data/schema.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/schema.sql b/data/schema.sql index 3dcf1401..c96908b8 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -878,7 +878,7 @@ CREATE VIEW customised_skin_data AS skins.custom_skin_id AS custom_skin_id, skins.base_skin_id AS base_skin_id, properties.property_id AS property_id, - properties.card_id AS card_id, + prop_ids.card_id AS card_id, prop_ids.property_name AS property_name, properties.value AS value FROM From 82e1141b2f05739810a64f26885261dd5aaa8ff6 Mon Sep 17 00:00:00 2001 From: Conatum Date: Tue, 26 Apr 2022 13:43:52 +0300 Subject: [PATCH 16/37] (client): Add `noop` interaction handling. --- bot/meta/client.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/bot/meta/client.py b/bot/meta/client.py index 267ffe28..0ea0d4f1 100644 --- a/bot/meta/client.py +++ b/bot/meta/client.py @@ -2,10 +2,12 @@ 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() @@ -19,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() From f5909e841438f6a0cbf6d3e3bb6e1c873061c75f Mon Sep 17 00:00:00 2001 From: Conatum Date: Tue, 26 Apr 2022 13:44:20 +0300 Subject: [PATCH 17/37] (interactions): Support callback extra `check`. --- bot/meta/interactions/components.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/meta/interactions/components.py b/bot/meta/interactions/components.py index 81f21145..d674d2ff 100644 --- a/bot/meta/interactions/components.py +++ b/bot/meta/interactions/components.py @@ -48,12 +48,12 @@ class AwaitableComponent: return await client.wait_for('interaction_create', timeout=timeout, check=_check) - def add_callback(self, timeout=None, repeat=True, pass_args=(), pass_kwargs={}): + def add_callback(self, timeout=None, repeat=True, check=None, pass_args=(), pass_kwargs={}): def wrapper(func): async def wrapped(): while True: try: - button_press = await self.wait_for(timeout=timeout) + button_press = await self.wait_for(timeout=timeout, check=check) except asyncio.TimeoutError: break asyncio.create_task(func(button_press, *pass_args, **pass_kwargs)) From d6141878a3fb938fa0128560c572d58d443253b7 Mon Sep 17 00:00:00 2001 From: Conatum Date: Tue, 26 Apr 2022 13:45:01 +0300 Subject: [PATCH 18/37] (interactions): Add interaction manager. --- bot/meta/interactions/__init__.py | 1 + bot/meta/interactions/manager.py | 94 +++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 bot/meta/interactions/manager.py diff --git a/bot/meta/interactions/__init__.py b/bot/meta/interactions/__init__.py index 660b5a93..b522979a 100644 --- a/bot/meta/interactions/__init__.py +++ b/bot/meta/interactions/__init__.py @@ -2,3 +2,4 @@ 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/manager.py b/bot/meta/interactions/manager.py new file mode 100644 index 00000000..7bdfab8b --- /dev/null +++ b/bot/meta/interactions/manager.py @@ -0,0 +1,94 @@ +import asyncio +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() + while True: + try: + await asyncio.sleep(diff) + except asyncio.CancelledError: + break + diff = self.expires_at - datetime.now() + if diff <= 0: + asyncio.create_task(self.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): + await self.cleanup_function(self, **kwargs) + + 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 timeout(self): + await self.timeout_function(self) + + async def close(self, **kwargs): + """ + Request closure of the manager. + """ + await self.close_function(self, **kwargs) + + 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 From 1434abf41652e73f56c01027ff83ea979eea9cf2 Mon Sep 17 00:00:00 2001 From: Conatum Date: Tue, 26 Apr 2022 16:16:14 +0300 Subject: [PATCH 19/37] fix (interactions): Fix timeout pathway. --- bot/meta/interactions/manager.py | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/bot/meta/interactions/manager.py b/bot/meta/interactions/manager.py index 7bdfab8b..fa6cfca7 100644 --- a/bot/meta/interactions/manager.py +++ b/bot/meta/interactions/manager.py @@ -1,4 +1,5 @@ import asyncio +import logging from datetime import datetime, timedelta @@ -18,15 +19,15 @@ class InteractionManager: self.cleaned_up = asyncio.Event() async def _timeout_loop(self): - diff = self.expires_at - datetime.now() + 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() + diff = (self.expires_at - datetime.now()).total_seconds() if diff <= 0: - asyncio.create_task(self.timeout()) + asyncio.create_task(self.run_timeout()) break def extend_timeout(self): @@ -45,7 +46,9 @@ class InteractionManager: 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())) + self.self_futures.append( + asyncio.create_task(self._timeout_loop()) + ) return self async def __aexit__(self, *args): @@ -64,7 +67,10 @@ class InteractionManager: return func async def cleanup(self, **kwargs): - await self.cleanup_function(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) @@ -73,14 +79,20 @@ class InteractionManager: self.timeout_function = func return func - async def timeout(self): - await self.timeout_function(self) + 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. """ - await self.close_function(self, **kwargs) + 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 From 917f85b7a001ed7773e8095b4389df39890cc2df Mon Sep 17 00:00:00 2001 From: Conatum Date: Tue, 26 Apr 2022 16:16:41 +0300 Subject: [PATCH 20/37] (interactions): Support `response_update`. --- bot/meta/interactions/interactions.py | 28 ++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/bot/meta/interactions/interactions.py b/bot/meta/interactions/interactions.py index c1ec18e9..055b577a 100644 --- a/bot/meta/interactions/interactions.py +++ b/bot/meta/interactions/interactions.py @@ -9,11 +9,15 @@ class Interaction: '_state' ) - async def response(self, content=None, embeds=None, components=None, ephemeral=None): + 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] @@ -30,6 +34,28 @@ class Interaction: 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, From 078959807e15810ae14e0864c2c50b79ceee25ae Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 27 Apr 2022 11:17:49 +0300 Subject: [PATCH 21/37] fix (interactions): Correctly support disabled. --- bot/meta/interactions/components.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/meta/interactions/components.py b/bot/meta/interactions/components.py index d674d2ff..ad2bdef9 100644 --- a/bot/meta/interactions/components.py +++ b/bot/meta/interactions/components.py @@ -96,6 +96,8 @@ class Button(MessageComponent, AwaitableComponent): 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 From a526d5e7b5e71baf5d0a051679c034d0c8a4e6f1 Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 27 Apr 2022 11:18:21 +0300 Subject: [PATCH 22/37] (interactions): Add response edit http patch. --- bot/meta/patches.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/bot/meta/patches.py b/bot/meta/patches.py index 8f25c39c..621004d9 100644 --- a/bot/meta/patches.py +++ b/bot/meta/patches.py @@ -119,6 +119,30 @@ def interaction_callback(self, interaction_id, interaction_token, callback_type, 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: @@ -130,6 +154,7 @@ 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, From 4c117d787bdc6030e9153d9314d1ddc8cf27b9c1 Mon Sep 17 00:00:00 2001 From: JetRaidz Date: Sun, 8 May 2022 00:28:02 +1200 Subject: [PATCH 23/37] (help): Rename Premium group to Support Us. - Premium group is now called Support Us. --- bot/modules/meta/help.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/modules/meta/help.py b/bot/modules/meta/help.py index ccdf0b59..4835636d 100644 --- a/bot/modules/meta/help.py +++ b/bot/modules/meta/help.py @@ -22,26 +22,26 @@ group_hints = { 'Guild Admin': "*Dangerous administration commands!*", 'Guild Configuration': "*Control how I behave in your server.*", 'Meta': "*Information about me!*", - 'Premium': "*Support the team and keep the project alive by using LionGems!*" + 'Support Us': "*Support the team and keep the project alive by using LionGems!*" } standard_group_order = ( - ('Pomodoro', 'Productivity', 'Premium', 'Statistics', 'Economy', 'Personal Settings', 'Meta'), + ('Pomodoro', 'Productivity', 'Support Us', 'Statistics', 'Economy', 'Personal Settings', 'Meta'), ) mod_group_order = ( ('Moderation', 'Meta'), - ('Pomodoro', 'Productivity', 'Premium', 'Statistics', 'Economy', 'Personal Settings') + ('Pomodoro', 'Productivity', 'Support Us', 'Statistics', 'Economy', 'Personal Settings') ) admin_group_order = ( ('Guild Admin', 'Guild Configuration', 'Moderation', 'Meta'), - ('Pomodoro', 'Productivity', 'Premium', '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', 'Premium', 'Statistics', 'Economy', 'Personal Settings') + ('Pomodoro', 'Productivity', 'Support Us', 'Statistics', 'Economy', 'Personal Settings') ) # Help embed format From e23544aff7439d98110d8f3ad1ea7ca61f3cb15d Mon Sep 17 00:00:00 2001 From: Conatum Date: Mon, 9 May 2022 08:07:55 +0300 Subject: [PATCH 24/37] (interactions): Add select menu `value` property. --- bot/meta/interactions/interactions.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/bot/meta/interactions/interactions.py b/bot/meta/interactions/interactions.py index 055b577a..3277e48d 100644 --- a/bot/meta/interactions/interactions.py +++ b/bot/meta/interactions/interactions.py @@ -109,6 +109,15 @@ class Selection(ComponentInteraction): 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__ = ( From ad5ef537c97088829cabb88c04f400beffb29748 Mon Sep 17 00:00:00 2001 From: Conatum Date: Mon, 9 May 2022 08:08:56 +0300 Subject: [PATCH 25/37] (interactions): Support component registration. --- bot/meta/interactions/manager.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bot/meta/interactions/manager.py b/bot/meta/interactions/manager.py index fa6cfca7..1511ad51 100644 --- a/bot/meta/interactions/manager.py +++ b/bot/meta/interactions/manager.py @@ -104,3 +104,11 @@ class InteractionManager: 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 From 53dbcd600f54bdc3b1e497dc34e356e5abbca1a0 Mon Sep 17 00:00:00 2001 From: Conatum Date: Mon, 9 May 2022 21:08:23 +0300 Subject: [PATCH 26/37] (interactions): Add menu `set_default`. --- bot/meta/interactions/components.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/bot/meta/interactions/components.py b/bot/meta/interactions/components.py index ad2bdef9..efc0b6c6 100644 --- a/bot/meta/interactions/components.py +++ b/bot/meta/interactions/components.py @@ -134,6 +134,22 @@ class SelectMenu(MessageComponent, AwaitableComponent): 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, From 3fbc3b1fcb76f44e59cad718379ee75826ae515c Mon Sep 17 00:00:00 2001 From: Conatum Date: Wed, 11 May 2022 22:04:43 +0300 Subject: [PATCH 27/37] (tasklist): Pass context to factory. --- bot/modules/todo/Tasklist.py | 2 +- bot/modules/todo/commands.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/modules/todo/Tasklist.py b/bot/modules/todo/Tasklist.py index 74df8d64..dc8737a5 100644 --- a/bot/modules/todo/Tasklist.py +++ b/bot/modules/todo/Tasklist.py @@ -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) 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), From 8c9e2e6cd8b340ce40c0b7c85397190bb851f5af Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 12 May 2022 00:22:27 +0300 Subject: [PATCH 28/37] (data): Implement guild contribution log. --- data/schema.sql | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/data/schema.sql b/data/schema.sql index c96908b8..67dcd197 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -910,13 +910,19 @@ CREATE VIEW user_active_skins AS -- Premium Guild Data {{{ CREATE TABLE premium_guilds( guildid BIGINT PRIMARY KEY REFERENCES guild_config, - premium_since TIMESTAMPTZ NOT NULL, - premium_until TIMESTAMPTZ NOT NULL, + 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() ); -- }}} From 5be84facd3b625c758f31e579a116ec41a3be530 Mon Sep 17 00:00:00 2001 From: Conatum Date: Thu, 12 May 2022 00:43:49 +0300 Subject: [PATCH 29/37] (premium): Remove ad messages from premium guilds. --- bot/modules/sponsors/module.py | 8 +++++++- bot/modules/topgg/module.py | 2 ++ 2 files changed, 9 insertions(+), 1 deletion(-) 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/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) From 011531dd69e27f665407cbd54dc9b76fc0ebe7f2 Mon Sep 17 00:00:00 2001 From: Conatum Date: Mon, 16 May 2022 18:52:27 +0300 Subject: [PATCH 30/37] (timer): Stop updating empty timers. --- bot/modules/study/timers/Timer.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/bot/modules/study/timers/Timer.py b/bot/modules/study/timers/Timer.py index df603aaa..64989f76 100644 --- a/bot/modules/study/timers/Timer.py +++ b/bot/modules/study/timers/Timer.py @@ -170,6 +170,18 @@ class Timer: self.last_seen[member.id] = utc_now() content = [] + + current_stage = self.current_stage + next_starts = int(current_stage.start.timestamp()) + if current_stage.name == 'BREAK': + content.append( + f"**Have a rest!** Break finishes ." + ) + else: + content.append( + f"**Focus!** Session ends ." + ) + if to_kick: # Do kick await asyncio.gather( @@ -386,7 +398,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()) From 4733715573a372cf27b2d6f9942658a6d7c92354 Mon Sep 17 00:00:00 2001 From: Conatum Date: Mon, 16 May 2022 18:53:48 +0300 Subject: [PATCH 31/37] fix (config): Multi-config emoji resolution. --- bot/meta/config.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 From a33d882e3f398e13cb9a5c8da647c3edbf02640c Mon Sep 17 00:00:00 2001 From: Conatum Date: Mon, 16 May 2022 19:55:45 +0300 Subject: [PATCH 32/37] (interactions): Add awaitable tracebacks. --- bot/meta/interactions/components.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/bot/meta/interactions/components.py b/bot/meta/interactions/components.py index efc0b6c6..f3d661cb 100644 --- a/bot/meta/interactions/components.py +++ b/bot/meta/interactions/components.py @@ -1,3 +1,5 @@ +import logging +import traceback import asyncio import uuid import json @@ -50,13 +52,30 @@ class AwaitableComponent: 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 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)) + asyncio.create_task(_func(button_press, *pass_args, **pass_kwargs)) if not repeat: break future = asyncio.create_task(wrapped()) From ec87cb7423f4a2917ccc784303243a85e9209945 Mon Sep 17 00:00:00 2001 From: Conatum Date: Mon, 16 May 2022 20:04:59 +0300 Subject: [PATCH 33/37] (TimeSlot): Add random dithers. Add startup and close dither for ratelimiting. --- bot/modules/accountability/TimeSlot.py | 5 +++++ 1 file changed, 5 insertions(+) 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() From c63b67d9cdb58ce281bb42141fd2ffbe6f8717f5 Mon Sep 17 00:00:00 2001 From: Conatum Date: Mon, 16 May 2022 21:00:44 +0300 Subject: [PATCH 34/37] (data): Complete v11-v12 migration. --- data/migration/v11-v12/migration.sql | 81 ++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/data/migration/v11-v12/migration.sql b/data/migration/v11-v12/migration.sql index 460f037b..0f5c0abd 100644 --- a/data/migration/v11-v12/migration.sql +++ b/data/migration/v11-v12/migration.sql @@ -24,4 +24,85 @@ CREATE TABLE gem_transactions( 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'); From 3c9a3dd2727f911c8bffb0ff3abcd1e5f8be9c3e Mon Sep 17 00:00:00 2001 From: Conatum Date: Tue, 17 May 2022 12:29:16 +0300 Subject: [PATCH 35/37] fix (Timer): Correctly handle status content. --- bot/modules/study/timers/Timer.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/bot/modules/study/timers/Timer.py b/bot/modules/study/timers/Timer.py index 64989f76..20a60c70 100644 --- a/bot/modules/study/timers/Timer.py +++ b/bot/modules/study/timers/Timer.py @@ -171,17 +171,6 @@ class Timer: content = [] - current_stage = self.current_stage - next_starts = int(current_stage.start.timestamp()) - if current_stage.name == 'BREAK': - content.append( - f"**Have a rest!** Break finishes ." - ) - else: - content.append( - f"**Focus!** Session ends ." - ) - if to_kick: # Do kick await asyncio.gather( @@ -206,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('✅') From 364dc2dde0b42a7d64313dd9afa12210278ba7db Mon Sep 17 00:00:00 2001 From: Conatum Date: Tue, 17 May 2022 13:43:26 +0300 Subject: [PATCH 36/37] style: Move to configured emojis. --- bot/modules/todo/Tasklist.py | 14 ++++++------ config/emojis.conf | 44 ++++++++++++++++++++++++++++++++++++ config/example-bot.conf | 32 ++------------------------ 3 files changed, 53 insertions(+), 37 deletions(-) create mode 100644 config/emojis.conf diff --git a/bot/modules/todo/Tasklist.py b/bot/modules/todo/Tasklist.py index dc8737a5..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 @@ -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/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 From b4da2e3650aa045bb449351449cc062e0f90c880 Mon Sep 17 00:00:00 2001 From: Conatum Date: Tue, 17 May 2022 13:50:26 +0300 Subject: [PATCH 37/37] (interactions): Handle cancel on callback. --- bot/meta/interactions/components.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/meta/interactions/components.py b/bot/meta/interactions/components.py index f3d661cb..087e3cde 100644 --- a/bot/meta/interactions/components.py +++ b/bot/meta/interactions/components.py @@ -55,6 +55,10 @@ class AwaitableComponent: 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()