Merge branch 'feature-interactions' into feature-gems

This commit is contained in:
2022-04-21 15:38:53 +03:00
11 changed files with 650 additions and 113 deletions

View File

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

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

@@ -1,4 +1,8 @@
from .logger import log, logger from .logger import log, logger
from . import interactions
from . import patches
from .client import client from .client import client
from .config import conf from .config import conf
from .args import args from .args import args

View File

@@ -1,6 +1,7 @@
from discord import Intents from discord import Intents
from cmdClient.cmdClient import cmdClient from cmdClient.cmdClient import cmdClient
from . import patches
from .config import conf from .config import conf
from .sharding import shard_number, shard_count from .sharding import shard_number, shard_count
from LionContext import LionContext from LionContext import LionContext

View File

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

View File

@@ -0,0 +1,149 @@
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, 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):
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()
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 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,133 @@
import asyncio
from .enums import ComponentType, InteractionType, InteractionCallback
class Interaction:
__slots__ = (
'id',
'token',
'_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,
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']
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,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

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

@@ -0,0 +1,261 @@
"""
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 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
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)
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']
)
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

@@ -29,6 +29,10 @@ Best of luck with your studies!
@client.add_after_event('guild_join', priority=0) @client.add_after_event('guild_join', priority=0)
async def post_join_message(client: cmdClient, guild: discord.Guild): async def post_join_message(client: cmdClient, guild: discord.Guild):
try:
await guild.me.edit(nick="Leo")
except discord.HTTPException:
pass
if (channel := guild.system_channel) and channel.permissions_for(guild.me).embed_links: if (channel := guild.system_channel) and channel.permissions_for(guild.me).embed_links:
embed = discord.Embed( embed = discord.Embed(
description=message description=message