Merge pull request #31 from StudyLions/staging

Interactions and Premium
This commit is contained in:
Interitio
2022-05-17 22:40:19 +03:00
committed by GitHub
26 changed files with 1186 additions and 171 deletions

View File

@@ -1,2 +1,2 @@
CONFIG_FILE = "config/bot.conf"
DATA_VERSION = 11
DATA_VERSION = 12

View File

@@ -1,7 +1,5 @@
from . import data # noqa
from . import patches
from .module import module
from .lion import Lion
from . import blacklists

View File

@@ -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)
)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -0,0 +1,5 @@
from . import enums
from .interactions import _component_interaction_factory, Interaction, ComponentInteraction, ModalResponse
from .components import *
from .modals import *
from .manager import InteractionManager

View File

@@ -0,0 +1,190 @@
import logging
import traceback
import asyncio
import uuid
import json
from .enums import ButtonStyle, InteractionType
class MessageComponent:
_type = None
interaction_type = InteractionType.MESSAGE_COMPONENT
def __init_(self, *args, **kwargs):
self.message = None
def to_dict(self):
raise NotImplementedError
def to_json(self):
return json.dumps(self.to_dict())
class ActionRow(MessageComponent):
_type = 1
def __init__(self, *components):
self.components = components
def to_dict(self):
data = {
"type": self._type,
"components": [comp.to_dict() for comp in self.components]
}
return data
class AwaitableComponent:
interaction_type: InteractionType = None
async def wait_for(self, timeout=None, check=None):
from meta import client
def _check(interaction):
valid = True
valid = valid and interaction.interaction_type == self.interaction_type
valid = valid and interaction.custom_id == self.custom_id
valid = valid and (check is None or check(interaction))
return valid
return await client.wait_for('interaction_create', timeout=timeout, check=_check)
def add_callback(self, timeout=None, repeat=True, check=None, pass_args=(), pass_kwargs={}):
def wrapper(func):
async def _func(*args, **kwargs):
try:
return await func(*args, **kwargs)
except asyncio.CancelledError:
pass
except asyncio.TimeoutError:
pass
except Exception:
from meta import client
full_traceback = traceback.format_exc()
client.log(
f"Caught an unhandled exception while executing interaction callback "
f"for interaction type '{self.interaction_type.name}' with id '{self.custom_id}'.\n"
f"{self!r}\n"
f"{func!r}\n"
f"{full_traceback}",
context=f"cid:{self.custom_id}",
level=logging.ERROR
)
async def wrapped():
while True:
try:
button_press = await self.wait_for(timeout=timeout, check=check)
except asyncio.TimeoutError:
break
asyncio.create_task(_func(button_press, *pass_args, **pass_kwargs))
if not repeat:
break
future = asyncio.create_task(wrapped())
return future
return wrapper
class Button(MessageComponent, AwaitableComponent):
_type = 2
def __init__(self, label, style=ButtonStyle.PRIMARY, custom_id=None, url=None, emoji=None, disabled=False):
if style == ButtonStyle.LINK:
if url is None:
raise ValueError("Link buttons must have a url")
custom_id = None
elif custom_id is None:
custom_id = str(uuid.uuid4())
self.label = label
self.style = style
self.custom_id = custom_id
self.url = url
self.emoji = emoji
self.disabled = disabled
def to_dict(self):
data = {
"type": self._type,
"label": self.label,
"style": int(self.style)
}
if self.style == ButtonStyle.LINK:
data['url'] = self.url
else:
data['custom_id'] = self.custom_id
if self.emoji is not None:
# TODO: This only supports PartialEmoji, not Emoji
data['emoji'] = self.emoji.to_dict()
if self.disabled:
data['disabled'] = self.disabled
return data
class SelectOption:
def __init__(self, label, value, description, emoji=None, default=False):
self.label = label
self.value = value
self.description = description
self.emoji = emoji
self.default = default
def to_dict(self):
data = {
"label": self.label,
"value": self.value,
"description": self.description,
}
if self.emoji:
data['emoji'] = self.emoji.to_dict()
if self.default:
data['default'] = self.default
return data
class SelectMenu(MessageComponent, AwaitableComponent):
_type = 3
def __init__(self, *options, custom_id=None, placeholder=None, min_values=None, max_values=None, disabled=False):
self.options = options
self.custom_id = custom_id or str(uuid.uuid4())
self.placeholder = placeholder
self.min_values = min_values
self.max_values = max_values
self.disabled = disabled
def set_default(self, value=None, index=None):
"""
Convenience method to set the default option.
"""
if index is not None and value is not None:
raise ValueError("Both index and value were supplied for the default.")
if index is not None:
for i, option in enumerate(self.options):
option.default = (i == index)
elif value is not None:
for option in self.options:
option.default = (option.value == value)
else:
for option in self.options:
option.default = False
def to_dict(self):
data = {
"type": self._type,
'custom_id': self.custom_id,
'options': [option.to_dict() for option in self.options],
}
if self.placeholder:
data['placeholder'] = self.placeholder
if self.min_values:
data['min_values'] = self.min_values
if self.max_values:
data['max_values'] = self.max_values
if self.disabled:
data['disabled'] = self.disabled
return data

View File

@@ -0,0 +1,40 @@
from enum import IntEnum
class InteractionType(IntEnum):
PING = 1
APPLICATION_COMMAND = 2
MESSAGE_COMPONENT = 3
APPLICATION_COMMAND_AUTOCOMPLETE = 4
MODAL_SUBMIT = 5
class ComponentType(IntEnum):
ACTIONROW = 1
BUTTON = 2
SELECTMENU = 3
TEXTINPUT = 4
class ButtonStyle(IntEnum):
PRIMARY = 1
SECONDARY = 2
SUCCESS = 3
DANGER = 4
LINK = 5
class TextInputStyle(IntEnum):
SHORT = 1
PARAGRAPH = 2
class InteractionCallback(IntEnum):
PONG = 1
CHANNEL_MESSAGE_WITH_SOURCE = 4
DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE = 5
DEFERRED_UPDATE_MESSAGE = 6
UPDATE_MESSAGE = 7
APPLICATION_COMMAND_AUTOCOMPLETE_RESULT = 8
MODAL = 9

View File

@@ -0,0 +1,168 @@
import asyncio
from .enums import ComponentType, InteractionType, InteractionCallback
class Interaction:
__slots__ = (
'id',
'token',
'_state'
)
async def response(self, content=None, embed=None, embeds=None, components=None, ephemeral=None):
data = {}
if content is not None:
data['content'] = str(content)
if embed is not None:
embeds = embeds or []
embeds.append(embed)
if embeds is not None:
data['embeds'] = [embed.to_dict() for embed in embeds]
if components is not None:
data['components'] = [component.to_dict() for component in components]
if ephemeral is not None:
data['flags'] = 1 << 6
return await self._state.http.interaction_callback(
self.id,
self.token,
InteractionCallback.CHANNEL_MESSAGE_WITH_SOURCE,
data
)
async def response_update(self, content=None, embed=None, embeds=None, components=None):
data = {}
if content is not None:
data['content'] = str(content)
if embed is not None:
embeds = embeds or []
embeds.append(embed)
if embeds is not None:
data['embeds'] = [embed.to_dict() for embed in embeds]
if components is not None:
data['components'] = [component.to_dict() for component in components]
return await self._state.http.interaction_callback(
self.id,
self.token,
InteractionCallback.UPDATE_MESSAGE,
data
)
async def response_deferred(self):
return await self._state.http.interaction_callback(
self.id,
self.token,
InteractionCallback.DEFERRED_UPDATE_MESSAGE
)
async def response_modal(self, modal):
return await self._state.http.interaction_callback(
self.id,
self.token,
InteractionCallback.MODAL,
modal.to_dict()
)
def ack(self):
asyncio.create_task(self.response_deferred())
class ComponentInteraction(Interaction):
interaction_type = InteractionType.MESSAGE_COMPONENT
# TODO: Slots
def __init__(self, message, user, data, state):
self.message = message
self.user = user
self._state = state
self._from_data(data)
def _from_data(self, data):
self.id = data['id']
self.token = data['token']
self.application_id = data['application_id']
component_data = data['data']
self.component_type = ComponentType(component_data['component_type'])
self.custom_id = component_data.get('custom_id', None)
class ButtonPress(ComponentInteraction):
__slots__ = ()
class Selection(ComponentInteraction):
__slots__ = ('values',)
def _from_data(self, data):
super()._from_data(data)
self.values = data['data']['values']
@property
def value(self):
if len(self.values) > 1:
raise ValueError("Cannot use 'value' property on multi-selection.")
elif len(self.values) == 1:
return self.values[0]
else:
return None
class ModalResponse(Interaction):
__slots__ = (
'message',
'user',
'_state'
'id',
'token',
'application_id',
'custom_id',
'values'
)
interaction_type = InteractionType.MODAL_SUBMIT
def __init__(self, message, user, data, state):
self.message = message
self.user = user
self._state = state
self._from_data(data)
def _from_data(self, data):
self.id = data['id']
self.token = data['token']
self.application_id = data['application_id']
component_data = data['data']
self.custom_id = component_data.get('custom_id', None)
values = {}
for row in component_data['components']:
for component in row['components']:
values[component['custom_id']] = component['value']
self.values = values
def _component_interaction_factory(data):
component_type = data['data']['component_type']
if component_type == ComponentType.BUTTON:
return ButtonPress
elif component_type == ComponentType.SELECTMENU:
return Selection
else:
return None

View File

@@ -0,0 +1,114 @@
import asyncio
import logging
from datetime import datetime, timedelta
class InteractionManager:
def __init__(self, timeout=600, extend=None):
self.futures = []
self.self_futures = []
self.cleanup_function = self._cleanup
self.timeout_function = self._timeout
self.close_function = self._close
self.timeout = timeout
self.extend = extend or timeout
self.expires_at = None
self.cleaned_up = asyncio.Event()
async def _timeout_loop(self):
diff = (self.expires_at - datetime.now()).total_seconds()
while True:
try:
await asyncio.sleep(diff)
except asyncio.CancelledError:
break
diff = (self.expires_at - datetime.now()).total_seconds()
if diff <= 0:
asyncio.create_task(self.run_timeout())
break
def extend_timeout(self):
new_expiry = max(datetime.now() + timedelta(seconds=self.extend), self.expires_at)
self.expires_at = new_expiry
async def wait(self):
"""
Wait until the manager is "done".
That is, until all the futures are done, or `closed` is set.
"""
closed_task = asyncio.create_task(self.cleaned_up.wait())
futures_task = asyncio.create_task(asyncio.wait(self.futures))
await asyncio.wait((closed_task, futures_task), return_when=asyncio.FIRST_COMPLETED)
async def __aenter__(self):
if self.timeout is not None:
self.expires_at = datetime.now() + timedelta(seconds=self.timeout)
self.self_futures.append(
asyncio.create_task(self._timeout_loop())
)
return self
async def __aexit__(self, *args):
if not self.cleaned_up.is_set():
await self.cleanup(exiting=True)
async def _cleanup(self, manager, timeout=False, closing=False, exiting=False, **kwargs):
for future in self.futures:
future.cancel()
for future in self.self_futures:
future.cancel()
self.cleaned_up.set()
def on_cleanup(self, func):
self.cleanup_function = func
return func
async def cleanup(self, **kwargs):
try:
await self.cleanup_function(self, **kwargs)
except Exception:
logging.debug("An error occurred while cleaning up the InteractionManager", exc_info=True)
async def _timeout(self, manager, **kwargs):
await self.cleanup(timeout=True, **kwargs)
def on_timeout(self, func):
self.timeout_function = func
return func
async def run_timeout(self):
try:
await self.timeout_function(self)
except Exception:
logging.debug("An error occurred while timing out the InteractionManager", exc_info=True)
async def close(self, **kwargs):
"""
Request closure of the manager.
"""
try:
await self.close_function(self, **kwargs)
except Exception:
logging.debug("An error occurred while closing the InteractionManager", exc_info=True)
def on_close(self, func):
self.close_function = func
return func
async def _close(self, manager, **kwargs):
await self.cleanup(closing=True, **kwargs)
def add_future(self, future):
self.futures.append(future)
return future
def register(self, component, callback, *args, **kwargs):
"""
Attaches the given awaitable interaction and adds the given callback.
"""
future = component.add_callback(*args, **kwargs)(callback)
self.add_future(future)
return component

View File

@@ -0,0 +1,54 @@
import uuid
from .enums import TextInputStyle, InteractionType
from .components import AwaitableComponent
class Modal(AwaitableComponent):
interaction_type = InteractionType.MODAL_SUBMIT
def __init__(self, title, *components, custom_id=None):
self.custom_id = custom_id or str(uuid.uuid4())
self.title = title
self.components = components
def to_dict(self):
data = {
'title': self.title,
'custom_id': self.custom_id,
'components': [comp.to_dict() for comp in self.components]
}
return data
class TextInput:
_type = 4
def __init__(
self,
label, placeholder=None, value=None, required=False,
style=TextInputStyle.SHORT, min_length=None, max_length=None,
custom_id=None
):
self.custom_id = custom_id or str(uuid.uuid4())
self.label = label
self.placeholder = placeholder
self.value = value
self.required = required
self.style = style
self.min_length = min_length
self.max_length = max_length
def to_dict(self):
data = {
'type': self._type,
'custom_id': self.custom_id,
'style': int(self.style),
'label': self.label,
}
for key in ('min_length', 'max_length', 'required', 'value', 'placeholder'):
if (value := getattr(self, key)) is not None:
data[key] = value
return data

289
bot/meta/patches.py Normal file
View File

@@ -0,0 +1,289 @@
"""
Temporary patches for the discord.py library to support new features of the discord API.
"""
import logging
from json import JSONEncoder
from discord.state import ConnectionState
from discord.http import Route, HTTPClient
from discord.abc import Messageable
from discord.utils import InvalidArgument, _get_as_snowflake, to_json
from discord import File, AllowedMentions, Member, User, Message
from .interactions import _component_interaction_factory, ModalResponse
from .interactions.enums import InteractionType
log = logging.getLogger(__name__)
def _default(self, obj):
return getattr(obj.__class__, "to_json", _default.default)(obj)
_default.default = JSONEncoder().default
JSONEncoder.default = _default
def send_message(self, channel_id, content, *, tts=False, embeds=None,
nonce=None, allowed_mentions=None, message_reference=None, components=None):
r = Route('POST', '/channels/{channel_id}/messages', channel_id=channel_id)
payload = {}
if content:
payload['content'] = content
if tts:
payload['tts'] = True
if embeds:
payload['embeds'] = embeds
if nonce:
payload['nonce'] = nonce
if allowed_mentions:
payload['allowed_mentions'] = allowed_mentions
if message_reference:
payload['message_reference'] = message_reference
if components:
payload['components'] = components
return self.request(r, json=payload)
def send_files(
self,
channel_id, *,
files,
content=None, tts=False, embed=None, embeds=None, nonce=None, allowed_mentions=None, message_reference=None,
components=None
):
r = Route('POST', '/channels/{channel_id}/messages', channel_id=channel_id)
form = []
payload = {'tts': tts}
if content:
payload['content'] = content
if embed:
payload['embed'] = embed
if embeds:
payload['embeds'] = embeds
if nonce:
payload['nonce'] = nonce
if allowed_mentions:
payload['allowed_mentions'] = allowed_mentions
if message_reference:
payload['message_reference'] = message_reference
if components:
payload['components'] = components
form.append({'name': 'payload_json', 'value': to_json(payload)})
if len(files) == 1:
file = files[0]
form.append({
'name': 'file',
'value': file.fp,
'filename': file.filename,
'content_type': 'application/octet-stream'
})
else:
for index, file in enumerate(files):
form.append({
'name': 'file%s' % index,
'value': file.fp,
'filename': file.filename,
'content_type': 'application/octet-stream'
})
return self.request(r, form=form, files=files)
def interaction_callback(self, interaction_id, interaction_token, callback_type, callback_data=None):
r = Route(
'POST',
'/interactions/{interaction_id}/{interaction_token}/callback',
interaction_id=interaction_id,
interaction_token=interaction_token
)
payload = {}
payload['type'] = int(callback_type)
if callback_data:
payload['data'] = callback_data
return self.request(r, json=payload)
def interaction_edit(self, application_id, interaction_token, content=None, embed=None, embeds=None, components=None):
r = Route(
'PATCH',
'/webhooks/{application_id}/{interaction_token}/messages/@original',
application_id=application_id,
interaction_token=interaction_token
)
payload = {}
if content is not None:
payload['content'] = str(content)
if embed is not None:
embeds = embeds or []
embeds.append(embed)
if embeds is not None:
payload['embeds'] = [embed.to_dict() for embed in embeds]
if components is not None:
payload['components'] = [component.to_dict() for component in components]
return self.request(r, json=payload)
def edit_message(self, channel_id, message_id, components=None, **fields):
r = Route('PATCH', '/channels/{channel_id}/messages/{message_id}', channel_id=channel_id, message_id=message_id)
if components is not None:
fields['components'] = [comp.to_dict() for comp in components]
return self.request(r, json=fields)
HTTPClient.send_files = send_files
HTTPClient.send_message = send_message
HTTPClient.edit_message = edit_message
HTTPClient.interaction_callback = interaction_callback
HTTPClient.interaction_edit = interaction_edit
async def send(self, content=None, *, tts=False, embed=None, embeds=None, file=None,
files=None, delete_after=None, nonce=None,
allowed_mentions=None, reference=None,
mention_author=None, components=None):
channel = await self._get_channel()
state = self._state
content = str(content) if content is not None else None
if embed is not None:
if embeds is not None:
embeds.append(embed)
else:
embeds = [embed]
embed = embed.to_dict()
if embeds is not None:
embeds = [embed.to_dict() for embed in embeds]
if components is not None:
components = [comp.to_dict() for comp in components]
if allowed_mentions is not None:
if state.allowed_mentions is not None:
allowed_mentions = state.allowed_mentions.merge(allowed_mentions).to_dict()
else:
allowed_mentions = allowed_mentions.to_dict()
else:
allowed_mentions = state.allowed_mentions and state.allowed_mentions.to_dict()
if mention_author is not None:
allowed_mentions = allowed_mentions or AllowedMentions().to_dict()
allowed_mentions['replied_user'] = bool(mention_author)
if reference is not None:
try:
reference = reference.to_message_reference_dict()
except AttributeError:
raise InvalidArgument('reference parameter must be Message or MessageReference') from None
if file is not None and files is not None:
raise InvalidArgument('cannot pass both file and files parameter to send()')
if file is not None:
if not isinstance(file, File):
raise InvalidArgument('file parameter must be File')
try:
data = await state.http.send_files(channel.id, files=[file], allowed_mentions=allowed_mentions,
content=content, tts=tts, embed=embed, nonce=nonce,
message_reference=reference, components=components)
finally:
file.close()
elif files is not None:
if len(files) > 10:
raise InvalidArgument('files parameter must be a list of up to 10 elements')
elif not all(isinstance(file, File) for file in files):
raise InvalidArgument('files parameter must be a list of File')
try:
data = await state.http.send_files(channel.id, files=files, content=content, tts=tts,
embeds=embeds, nonce=nonce, allowed_mentions=allowed_mentions,
message_reference=reference, components=components)
finally:
for f in files:
f.close()
else:
data = await state.http.send_message(channel.id, content, tts=tts, embeds=embeds,
nonce=nonce, allowed_mentions=allowed_mentions,
message_reference=reference, components=components)
ret = state.create_message(channel=channel, data=data)
if delete_after is not None:
await ret.delete(delay=delete_after)
return ret
Messageable.send = send
def parse_interaction_create(self, data):
self.dispatch('raw_interaction_create', data)
if (guild_id := data.get('guild_id', None)):
guild = self._get_guild(int(guild_id))
if guild is None:
log.debug('INTERACTION_CREATE referencing an unknown guild ID: %s. Discarding.', guild_id)
return
else:
guild = None
if (member_data := data.get('member', None)) is not None:
# Construct member
# TODO: Theoretical reliance on cached guild
user = Member(data=member_data, guild=guild, state=self)
else:
# Assume user
user = self.get_user(_get_as_snowflake(data['user'], 'id')) or User(data=data['user'], state=self)
if 'message' in data:
message = self._get_message(_get_as_snowflake(data['message'], 'id'))
if not message:
message_data = data['message']
channel, _ = self._get_guild_channel(message_data)
message = Message(data=message_data, channel=channel, state=self)
if self._messages is not None:
self._messages.append(message)
else:
message = None
interaction = None
if data['type'] == InteractionType.MESSAGE_COMPONENT:
interaction_class = _component_interaction_factory(data)
if interaction_class:
interaction = interaction_class(message, user, data, self)
else:
log.debug(
'INTERACTION_CREATE recieved unhandled message component interaction type: %s',
data['data']['component_type']
)
elif data['type'] == InteractionType.MODAL_SUBMIT:
interaction = ModalResponse(message, user, data, self)
else:
log.debug('INTERACTION_CREATE recieved unhandled interaction type: %s', data['type'])
log.debug(data)
interaction = None
if interaction:
self.dispatch('interaction_create', interaction)
ConnectionState.parse_interaction_create = parse_interaction_create

View File

@@ -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()

View File

@@ -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

View File

@@ -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()

View File

@@ -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())

View File

@@ -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:

View File

@@ -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),

View File

@@ -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)

44
config/emojis.conf Normal file
View File

@@ -0,0 +1,44 @@
[EMOJIS]
lionyay = <:lionyay:933610591388581890>
lionlove = <:lionlove:933610591459872868>
progress_left_empty = <:1dark:933826583934959677>
progress_left_full = <:3_:933820201840021554>
progress_middle_full = <:5_:933823492221173860>
progress_middle_transition = <:6_:933824739414278184>
progress_middle_empty = <:7_:933825425631752263>
progress_right_empty = <:8fixed:933827434950844468>
progress_right_full = <:9full:933828043548524634>
inactive_achievement_1 = <:1:936107534748635166>
inactive_achievement_2 = <:2:936107534815735879>
inactive_achievement_3 = <:3:936107534782193704>
inactive_achievement_4 = <:4:936107534358577203>
inactive_achievement_5 = <:5:936107534715088926>
inactive_achievement_6 = <:6:936107534169833483>
inactive_achievement_7 = <:7:936107534723448832>
inactive_achievement_8 = <:8:936107534706688070>
active_achievement_1 = <:a1_:936107582786011196>
active_achievement_2 = <:a2_:936107582693720104>
active_achievement_3 = <:a3_:936107582760824862>
active_achievement_4 = <:a4_:936107582614028348>
active_achievement_5 = <:a5_:936107582630809620>
active_achievement_6 = <:a6_:936107582609821726>
active_achievement_7 = <:a7_:936107582546935818>
active_achievement_8 = <:a8_:936107582626623498>
gem = <:gem:975880967891845210>
skin_equipped = <:checkmarkthinbigger:975880828494151711>
skin_owned = <:greycheckmarkthinbigger:975880828485767198>
skin_unowned = <:newbigger:975880828712288276>
backward = <:arrowbackwardbigger:975880828460597288>
forward = <:arrowforwardbigger:975880828624199710>
person = <:person01:975880828481581096>
question = <:questionmarkbigger:975880828645167154>
cancel = <:xbigger:975880828653568012>
refresh = <:cyclebigger:975880828611600404>

View File

@@ -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

View File

@@ -0,0 +1,108 @@
-- Add gem support
ALTER TABLE user_config ADD COLUMN gems INTEGER DEFAULT 0;
-- LionGem audit log {{{
CREATE TYPE GemTransactionType AS ENUM (
'ADMIN',
'GIFT',
'PURCHASE',
'AUTOMATIC'
);
CREATE TABLE gem_transactions(
transactionid SERIAL PRIMARY KEY,
transaction_type GemTransactionType NOT NULL,
actorid BIGINT NOT NULL,
from_account BIGINT,
to_account BIGINT,
amount INTEGER NOT NULL,
description TEXT NOT NULL,
note TEXT,
reference TEXT,
_timestamp TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX gem_transactions_from ON gem_transactions (from_account);
-- }}}
-- Skin Data {{{
CREATE TABLE global_available_skins(
skin_id SERIAL PRIMARY KEY,
skin_name TEXT NOT NULL
);
CREATE INDEX global_available_skin_names ON global_available_skins (skin_name);
CREATE TABLE customised_skins(
custom_skin_id SERIAL PRIMARY KEY,
base_skin_id INTEGER REFERENCES global_available_skins (skin_id),
_timestamp TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE customised_skin_property_ids(
property_id SERIAL PRIMARY KEY,
card_id TEXT NOT NULL,
property_name TEXT NOT NULL,
UNIQUE(card_id, property_name)
);
CREATE TABLE customised_skin_properties(
custom_skin_id INTEGER NOT NULL REFERENCES customised_skins (custom_skin_id),
property_id INTEGER NOT NULL REFERENCES customised_skin_property_ids (property_id),
value TEXT NOT NULL,
PRIMARY KEY (custom_skin_id, property_id)
);
CREATE INDEX customised_skin_property_skin_id ON customised_skin_properties(custom_skin_id);
CREATE VIEW customised_skin_data AS
SELECT
skins.custom_skin_id AS custom_skin_id,
skins.base_skin_id AS base_skin_id,
properties.property_id AS property_id,
prop_ids.card_id AS card_id,
prop_ids.property_name AS property_name,
properties.value AS value
FROM
customised_skins skins
LEFT JOIN customised_skin_properties properties ON skins.custom_skin_id = properties.custom_skin_id
LEFT JOIN customised_skin_property_ids prop_ids ON properties.property_id = prop_ids.property_id;
CREATE TABLE user_skin_inventory(
itemid SERIAL PRIMARY KEY,
userid BIGINT NOT NULL REFERENCES user_config (userid) ON DELETE CASCADE,
custom_skin_id INTEGER NOT NULL REFERENCES customised_skins (custom_skin_id) ON DELETE CASCADE,
transactionid INTEGER REFERENCES gem_transactions (transactionid),
active BOOLEAN NOT NULL DEFAULT FALSE,
acquired_at TIMESTAMPTZ DEFAULT now(),
expires_at TIMESTAMPTZ
);
CREATE INDEX user_skin_inventory_users ON user_skin_inventory(userid);
CREATE UNIQUE INDEX user_skin_inventory_active ON user_skin_inventory(userid) WHERE active = TRUE;
CREATE VIEW user_active_skins AS
SELECT
*
FROM user_skin_inventory
WHERE active=True;
-- }}}
-- Premium Guild Data {{{
CREATE TABLE premium_guilds(
guildid BIGINT PRIMARY KEY REFERENCES guild_config,
premium_since TIMESTAMPTZ NOT NULL DEFAULT now(),
premium_until TIMESTAMPTZ NOT NULL DEFAULT now(),
custom_skin_id INTEGER REFERENCES customised_skins
);
-- Contributions members have made to guild premium funds
CREATE TABLE premium_guild_contributions(
contributionid SERIAL PRIMARY KEY,
userid BIGINT NOT NULL REFERENCES user_config,
guildid BIGINT NOT NULL REFERENCES premium_guilds,
transactionid INTEGER REFERENCES gem_transactions,
duration INTEGER NOT NULL,
_timestamp TIMESTAMPTZ DEFAULT now()
);
-- }}}
INSERT INTO VersionHistory (version, author) VALUES (12, 'v11-v12 migration');

View File

@@ -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: