Añadiendo todos los archivos del proyecto (incluidos secretos y venv)

This commit is contained in:
2026-03-06 18:31:45 -06:00
parent 3a15a3eafa
commit e4d50b6eb5
4965 changed files with 991048 additions and 0 deletions

View File

@@ -0,0 +1,98 @@
"""
Discord API Wrapper
~~~~~~~~~~~~~~~~~~~
A basic wrapper for the Discord API.
:copyright: (c) 2015-present Rapptz
:license: MIT, see LICENSE for more details.
"""
__title__ = 'discord'
__author__ = 'Rapptz'
__license__ = 'MIT'
__copyright__ = 'Copyright 2015-present Rapptz'
__version__ = '2.6.4'
__path__ = __import__('pkgutil').extend_path(__path__, __name__)
import logging
from typing import NamedTuple, Literal
from .client import *
from .appinfo import *
from .user import *
from .emoji import *
from .partial_emoji import *
from .activity import *
from .channel import *
from .guild import *
from .flags import *
from .member import *
from .message import *
from .asset import *
from .errors import *
from .permissions import *
from .role import *
from .file import *
from .colour import *
from .integrations import *
from .invite import *
from .template import *
from .welcome_screen import *
from .sku import *
from .widget import *
from .object import *
from .reaction import *
from . import (
utils as utils,
opus as opus,
abc as abc,
ui as ui,
app_commands as app_commands,
)
from .enums import *
from .embeds import *
from .mentions import *
from .shard import *
from .player import *
from .webhook import *
from .voice_client import *
from .audit_logs import *
from .raw_models import *
from .team import *
from .sticker import *
from .stage_instance import *
from .scheduled_event import *
from .interactions import *
from .components import *
from .threads import *
from .automod import *
from .poll import *
from .soundboard import *
from .subscription import *
from .presences import *
from .primary_guild import *
from .onboarding import *
class VersionInfo(NamedTuple):
major: int
minor: int
micro: int
releaselevel: Literal['alpha', 'beta', 'candidate', 'final']
serial: int
version_info: VersionInfo = VersionInfo(major=2, minor=6, micro=4, releaselevel='final', serial=0)
logging.getLogger(__name__).addHandler(logging.NullHandler())
# This is a backwards compatibility hack and should be removed in v3
# Essentially forcing the exception to have different base classes
# In the future, this should only inherit from ClientException
if len(MissingApplicationID.__bases__) == 1:
MissingApplicationID.__bases__ = (app_commands.AppCommandError, ClientException)
del logging, NamedTuple, Literal, VersionInfo

View File

@@ -0,0 +1,357 @@
"""
The MIT License (MIT)
Copyright (c) 2015-present Rapptz
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
from typing import Optional, Tuple, Dict
import argparse
import sys
from pathlib import Path, PurePath, PureWindowsPath
import discord
import importlib.metadata
import aiohttp
import platform
def show_version() -> None:
entries = []
entries.append('- Python v{0.major}.{0.minor}.{0.micro}-{0.releaselevel}'.format(sys.version_info))
version_info = discord.version_info
entries.append('- discord.py v{0.major}.{0.minor}.{0.micro}-{0.releaselevel}'.format(version_info))
if version_info.releaselevel != 'final':
version = importlib.metadata.version('discord.py')
if version:
entries.append(f' - discord.py metadata: v{version}')
entries.append(f'- aiohttp v{aiohttp.__version__}')
uname = platform.uname()
entries.append('- system info: {0.system} {0.release} {0.version}'.format(uname))
print('\n'.join(entries))
def core(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None:
if args.version:
show_version()
else:
parser.print_help()
_bot_template = """#!/usr/bin/env python3
from discord.ext import commands
import discord
import config
class Bot(commands.{base}):
def __init__(self, intents: discord.Intents, **kwargs):
super().__init__(command_prefix=commands.when_mentioned_or('{prefix}'), intents=intents, **kwargs)
async def setup_hook(self):
for cog in config.cogs:
try:
await self.load_extension(cog)
except Exception as exc:
print(f'Could not load extension {{cog}} due to {{exc.__class__.__name__}}: {{exc}}')
async def on_ready(self):
print(f'Logged on as {{self.user}} (ID: {{self.user.id}})')
intents = discord.Intents.default()
intents.message_content = True
bot = Bot(intents=intents)
# write general commands here
bot.run(config.token)
"""
_gitignore_template = """# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg
# Our configuration files
config.py
"""
_cog_template = '''from discord.ext import commands
import discord
class {name}(commands.Cog{attrs}):
"""The description for {name} goes here."""
def __init__(self, bot):
self.bot = bot
{extra}
async def setup(bot):
await bot.add_cog({name}(bot))
'''
_cog_extras = """
async def cog_load(self):
# loading logic goes here
pass
async def cog_unload(self):
# clean up logic goes here
pass
async def cog_check(self, ctx):
# checks that apply to every command in here
return True
async def bot_check(self, ctx):
# checks that apply to every command to the bot
return True
async def bot_check_once(self, ctx):
# check that apply to every command but is guaranteed to be called only once
return True
async def cog_command_error(self, ctx, error):
# error handling to every command in here
pass
async def cog_app_command_error(self, interaction, error):
# error handling to every application command in here
pass
async def cog_before_invoke(self, ctx):
# called before a command is called here
pass
async def cog_after_invoke(self, ctx):
# called after a command is called here
pass
"""
# certain file names and directory names are forbidden
# see: https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247%28v=vs.85%29.aspx
# although some of this doesn't apply to Linux, we might as well be consistent
_base_table: Dict[str, Optional[str]] = {
'<': '-',
'>': '-',
':': '-',
'"': '-',
# '/': '-', these are fine
# '\\': '-',
'|': '-',
'?': '-',
'*': '-',
}
# NUL (0) and 1-31 are disallowed
_base_table.update((chr(i), None) for i in range(32))
_translation_table = str.maketrans(_base_table)
def to_path(parser: argparse.ArgumentParser, name: str, *, replace_spaces: bool = False) -> Path:
if isinstance(name, Path):
return name
if sys.platform == 'win32':
forbidden = (
'CON',
'PRN',
'AUX',
'NUL',
'COM1',
'COM2',
'COM3',
'COM4',
'COM5',
'COM6',
'COM7',
'COM8',
'COM9',
'LPT1',
'LPT2',
'LPT3',
'LPT4',
'LPT5',
'LPT6',
'LPT7',
'LPT8',
'LPT9',
)
if len(name) <= 4 and name.upper() in forbidden:
parser.error('invalid directory name given, use a different one')
path = PurePath(name)
if isinstance(path, PureWindowsPath) and path.drive:
drive, rest = path.parts[0], path.parts[1:]
transformed = tuple(map(lambda p: p.translate(_translation_table), rest))
name = drive + '\\'.join(transformed)
else:
name = name.translate(_translation_table)
if replace_spaces:
name = name.replace(' ', '-')
return Path(name)
def newbot(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None:
new_directory = to_path(parser, args.directory) / to_path(parser, args.name)
# as a note exist_ok for Path is a 3.5+ only feature
# since we already checked above that we're >3.5
try:
new_directory.mkdir(exist_ok=True, parents=True)
except OSError as exc:
parser.error(f'could not create our bot directory ({exc})')
cogs = new_directory / 'cogs'
try:
cogs.mkdir(exist_ok=True)
init = cogs / '__init__.py'
init.touch()
except OSError as exc:
print(f'warning: could not create cogs directory ({exc})')
try:
with open(str(new_directory / 'config.py'), 'w', encoding='utf-8') as fp:
fp.write('token = "place your token here"\ncogs = []\n')
except OSError as exc:
parser.error(f'could not create config file ({exc})')
try:
with open(str(new_directory / 'bot.py'), 'w', encoding='utf-8') as fp:
base = 'Bot' if not args.sharded else 'AutoShardedBot'
fp.write(_bot_template.format(base=base, prefix=args.prefix))
except OSError as exc:
parser.error(f'could not create bot file ({exc})')
if not args.no_git:
try:
with open(str(new_directory / '.gitignore'), 'w', encoding='utf-8') as fp:
fp.write(_gitignore_template)
except OSError as exc:
print(f'warning: could not create .gitignore file ({exc})')
print('successfully made bot at', new_directory)
def newcog(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None:
cog_dir = to_path(parser, args.directory)
try:
cog_dir.mkdir(exist_ok=True)
except OSError as exc:
print(f'warning: could not create cogs directory ({exc})')
directory = cog_dir / to_path(parser, args.name)
directory = directory.with_suffix('.py')
try:
with open(str(directory), 'w', encoding='utf-8') as fp:
attrs = ''
extra = _cog_extras if args.full else ''
if args.class_name:
name = args.class_name
else:
name = str(directory.stem)
if '-' in name or '_' in name:
translation = str.maketrans('-_', ' ')
name = name.translate(translation).title().replace(' ', '')
else:
name = name.title()
if args.display_name:
attrs += f', name="{args.display_name}"'
if args.hide_commands:
attrs += ', command_attrs=dict(hidden=True)'
fp.write(_cog_template.format(name=name, extra=extra, attrs=attrs))
except OSError as exc:
parser.error(f'could not create cog file ({exc})')
else:
print('successfully made cog at', directory)
def add_newbot_args(subparser: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
parser = subparser.add_parser('newbot', help='creates a command bot project quickly')
parser.set_defaults(func=newbot)
parser.add_argument('name', help='the bot project name')
parser.add_argument('directory', help='the directory to place it in (default: .)', nargs='?', default=Path.cwd())
parser.add_argument('--prefix', help='the bot prefix (default: $)', default='$', metavar='<prefix>')
parser.add_argument('--sharded', help='whether to use AutoShardedBot', action='store_true')
parser.add_argument('--no-git', help='do not create a .gitignore file', action='store_true', dest='no_git')
def add_newcog_args(subparser: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
parser = subparser.add_parser('newcog', help='creates a new cog template quickly')
parser.set_defaults(func=newcog)
parser.add_argument('name', help='the cog name')
parser.add_argument('directory', help='the directory to place it in (default: cogs)', nargs='?', default=Path('cogs'))
parser.add_argument('--class-name', help='the class name of the cog (default: <name>)', dest='class_name')
parser.add_argument('--display-name', help='the cog name (default: <name>)')
parser.add_argument('--hide-commands', help='whether to hide all commands in the cog', action='store_true')
parser.add_argument('--full', help='add all special methods as well', action='store_true')
def parse_args() -> Tuple[argparse.ArgumentParser, argparse.Namespace]:
parser = argparse.ArgumentParser(prog='discord', description='Tools for helping with discord.py')
parser.add_argument('-v', '--version', action='store_true', help='shows the library version')
parser.set_defaults(func=core)
subparser = parser.add_subparsers(dest='subcommand', title='subcommands')
add_newbot_args(subparser)
add_newcog_args(subparser)
return parser, parser.parse_args()
def main() -> None:
parser, args = parse_args()
args.func(parser, args)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,34 @@
"""
The MIT License (MIT)
Copyright (c) 2015-present Rapptz
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
from typing import TypeVar, TYPE_CHECKING
if TYPE_CHECKING:
from typing_extensions import TypeVar
from .client import Client
ClientT = TypeVar('ClientT', bound=Client, covariant=True, default=Client)
else:
ClientT = TypeVar('ClientT', bound='Client', covariant=True)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,900 @@
"""
The MIT License (MIT)
Copyright (c) 2015-present Rapptz
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
import datetime
from typing import Any, Dict, List, Optional, TYPE_CHECKING, Union, overload
from .asset import Asset
from .enums import ActivityType, StatusDisplayType, try_enum
from .colour import Colour
from .partial_emoji import PartialEmoji
from .utils import _get_as_snowflake
__all__ = (
'BaseActivity',
'Activity',
'Streaming',
'Game',
'Spotify',
'CustomActivity',
)
"""If curious, this is the current schema for an activity.
It's fairly long so I will document it here:
All keys are optional.
state: str (max: 128),
details: str (max: 128)
timestamps: dict
start: int (min: 1)
end: int (min: 1)
assets: dict
large_image: str (max: 32)
large_text: str (max: 128)
small_image: str (max: 32)
small_text: str (max: 128)
party: dict
id: str (max: 128),
size: List[int] (max-length: 2)
elem: int (min: 1)
secrets: dict
match: str (max: 128)
join: str (max: 128)
spectate: str (max: 128)
instance: bool
application_id: str
name: str (max: 128)
url: str
type: int
sync_id: str
session_id: str
flags: int
buttons: list[str (max: 32)]
There are also activity flags which are mostly uninteresting for the library atm.
t.ActivityFlags = {
INSTANCE: 1,
JOIN: 2,
SPECTATE: 4,
JOIN_REQUEST: 8,
SYNC: 16,
PLAY: 32
}
"""
if TYPE_CHECKING:
from .types.activity import (
Activity as ActivityPayload,
ActivityTimestamps,
ActivityParty,
ActivityAssets,
)
from .state import ConnectionState
class BaseActivity:
"""The base activity that all user-settable activities inherit from.
A user-settable activity is one that can be used in :meth:`Client.change_presence`.
The following types currently count as user-settable:
- :class:`Activity`
- :class:`Game`
- :class:`Streaming`
- :class:`CustomActivity`
Note that although these types are considered user-settable by the library,
Discord typically ignores certain combinations of activity depending on
what is currently set. This behaviour may change in the future so there are
no guarantees on whether Discord will actually let you set these types.
.. versionadded:: 1.3
"""
__slots__ = ('_created_at',)
def __init__(self, **kwargs: Any) -> None:
self._created_at: Optional[float] = kwargs.pop('created_at', None)
@property
def created_at(self) -> Optional[datetime.datetime]:
"""Optional[:class:`datetime.datetime`]: When the user started doing this activity in UTC.
.. versionadded:: 1.3
"""
if self._created_at is not None:
return datetime.datetime.fromtimestamp(self._created_at / 1000, tz=datetime.timezone.utc)
def to_dict(self) -> ActivityPayload:
raise NotImplementedError
class Activity(BaseActivity):
"""Represents an activity in Discord.
This could be an activity such as streaming, playing, listening
or watching.
For memory optimisation purposes, some activities are offered in slimmed
down versions:
- :class:`Game`
- :class:`Streaming`
Attributes
------------
application_id: Optional[:class:`int`]
The application ID of the game.
name: Optional[:class:`str`]
The name of the activity.
url: Optional[:class:`str`]
A stream URL that the activity could be doing.
type: :class:`ActivityType`
The type of activity currently being done.
state: Optional[:class:`str`]
The user's current state. For example, "In Game".
details: Optional[:class:`str`]
The detail of the user's current activity.
platform: Optional[:class:`str`]
The user's current platform.
.. versionadded:: 2.4
timestamps: :class:`dict`
A dictionary of timestamps. It contains the following optional keys:
- ``start``: Corresponds to when the user started doing the
activity in milliseconds since Unix epoch.
- ``end``: Corresponds to when the user will finish doing the
activity in milliseconds since Unix epoch.
assets: :class:`dict`
A dictionary representing the images and their hover text of an activity.
It contains the following optional keys:
- ``large_image``: A string representing the ID for the large image asset.
- ``large_text``: A string representing the text when hovering over the large image asset.
- ``large_url``: A string representing the URL of the large image asset.
- ``small_image``: A string representing the ID for the small image asset.
- ``small_text``: A string representing the text when hovering over the small image asset.
- ``small_url``: A string representing the URL of the small image asset.
party: :class:`dict`
A dictionary representing the activity party. It contains the following optional keys:
- ``id``: A string representing the party ID.
- ``size``: A list of up to two integer elements denoting (current_size, maximum_size).
buttons: List[:class:`str`]
A list of strings representing the labels of custom buttons shown in a rich presence.
.. versionadded:: 2.0
emoji: Optional[:class:`PartialEmoji`]
The emoji that belongs to this activity.
details_url: Optional[:class:`str`]
A URL that is linked to when clicking on the details text of the activity.
.. versionadded:: 2.6
state_url: Optional[:class:`str`]
A URL that is linked to when clicking on the state text of the activity.
.. versionadded:: 2.6
status_display_type: Optional[:class:`StatusDisplayType`]
Determines which field from the user's status text is displayed
in the members list.
.. versionadded:: 2.6
"""
__slots__ = (
'state',
'details',
'timestamps',
'platform',
'assets',
'party',
'flags',
'sync_id',
'session_id',
'type',
'name',
'url',
'application_id',
'emoji',
'buttons',
'state_url',
'details_url',
'status_display_type',
)
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.state: Optional[str] = kwargs.pop('state', None)
self.details: Optional[str] = kwargs.pop('details', None)
self.timestamps: ActivityTimestamps = kwargs.pop('timestamps', {})
self.platform: Optional[str] = kwargs.pop('platform', None)
self.assets: ActivityAssets = kwargs.pop('assets', {})
self.party: ActivityParty = kwargs.pop('party', {})
self.application_id: Optional[int] = _get_as_snowflake(kwargs, 'application_id')
self.name: Optional[str] = kwargs.pop('name', None)
self.url: Optional[str] = kwargs.pop('url', None)
self.flags: int = kwargs.pop('flags', 0)
self.sync_id: Optional[str] = kwargs.pop('sync_id', None)
self.session_id: Optional[str] = kwargs.pop('session_id', None)
self.buttons: List[str] = kwargs.pop('buttons', [])
activity_type = kwargs.pop('type', -1)
self.type: ActivityType = (
activity_type if isinstance(activity_type, ActivityType) else try_enum(ActivityType, activity_type)
)
emoji = kwargs.pop('emoji', None)
self.emoji: Optional[PartialEmoji] = PartialEmoji.from_dict(emoji) if emoji is not None else None
self.state_url: Optional[str] = kwargs.pop('state_url', None)
self.details_url: Optional[str] = kwargs.pop('details_url', None)
status_display_type = kwargs.pop('status_display_type', None)
self.status_display_type: Optional[StatusDisplayType] = (
status_display_type
if isinstance(status_display_type, StatusDisplayType)
else try_enum(StatusDisplayType, status_display_type)
if status_display_type is not None
else None
)
def __repr__(self) -> str:
attrs = (
('type', self.type),
('name', self.name),
('url', self.url),
('platform', self.platform),
('details', self.details),
('application_id', self.application_id),
('session_id', self.session_id),
('emoji', self.emoji),
)
inner = ' '.join('%s=%r' % t for t in attrs)
return f'<Activity {inner}>'
def to_dict(self) -> Dict[str, Any]:
ret: Dict[str, Any] = {}
for attr in self.__slots__:
value = getattr(self, attr, None)
if value is None:
continue
if isinstance(value, dict) and len(value) == 0:
continue
ret[attr] = value
ret['type'] = int(self.type)
if self.emoji:
ret['emoji'] = self.emoji.to_dict()
if self.status_display_type:
ret['status_display_type'] = int(self.status_display_type.value)
return ret
@property
def start(self) -> Optional[datetime.datetime]:
"""Optional[:class:`datetime.datetime`]: When the user started doing this activity in UTC, if applicable."""
try:
timestamp = self.timestamps['start'] / 1000 # pyright: ignore[reportTypedDictNotRequiredAccess]
except KeyError:
return None
else:
return datetime.datetime.fromtimestamp(timestamp, tz=datetime.timezone.utc)
@property
def end(self) -> Optional[datetime.datetime]:
"""Optional[:class:`datetime.datetime`]: When the user will stop doing this activity in UTC, if applicable."""
try:
timestamp = self.timestamps['end'] / 1000 # pyright: ignore[reportTypedDictNotRequiredAccess]
except KeyError:
return None
else:
return datetime.datetime.fromtimestamp(timestamp, tz=datetime.timezone.utc)
@property
def large_image_url(self) -> Optional[str]:
"""Optional[:class:`str`]: Returns a URL pointing to the large image asset of this activity, if applicable."""
try:
large_image = self.assets['large_image'] # pyright: ignore[reportTypedDictNotRequiredAccess]
except KeyError:
return None
else:
return self._image_url(large_image)
@property
def small_image_url(self) -> Optional[str]:
"""Optional[:class:`str`]: Returns a URL pointing to the small image asset of this activity, if applicable."""
try:
small_image = self.assets['small_image'] # pyright: ignore[reportTypedDictNotRequiredAccess]
except KeyError:
return None
else:
return self._image_url(small_image)
def _image_url(self, image: str) -> Optional[str]:
if image.startswith('mp:'):
return f'https://media.discordapp.net/{image[3:]}'
elif self.application_id is not None:
return Asset.BASE + f'/app-assets/{self.application_id}/{image}.png'
@property
def large_image_text(self) -> Optional[str]:
"""Optional[:class:`str`]: Returns the large image asset hover text of this activity, if applicable."""
return self.assets.get('large_text', None)
@property
def small_image_text(self) -> Optional[str]:
"""Optional[:class:`str`]: Returns the small image asset hover text of this activity, if applicable."""
return self.assets.get('small_text', None)
class Game(BaseActivity):
"""A slimmed down version of :class:`Activity` that represents a Discord game.
This is typically displayed via **Playing** on the official Discord client.
.. container:: operations
.. describe:: x == y
Checks if two games are equal.
.. describe:: x != y
Checks if two games are not equal.
.. describe:: hash(x)
Returns the game's hash.
.. describe:: str(x)
Returns the game's name.
Parameters
-----------
name: :class:`str`
The game's name.
Attributes
-----------
name: :class:`str`
The game's name.
platform: Optional[:class:`str`]
Where the user is playing from (ie. PS5, Xbox).
.. versionadded:: 2.4
assets: :class:`dict`
A dictionary representing the images and their hover text of a game.
It contains the following optional keys:
- ``large_image``: A string representing the ID for the large image asset.
- ``large_text``: A string representing the text when hovering over the large image asset.
- ``small_image``: A string representing the ID for the small image asset.
- ``small_text``: A string representing the text when hovering over the small image asset.
.. versionadded:: 2.4
"""
__slots__ = ('name', '_end', '_start', 'platform', 'assets')
def __init__(self, name: str, **extra: Any) -> None:
super().__init__(**extra)
self.name: str = name
self.platform: Optional[str] = extra.get('platform')
self.assets: ActivityAssets = extra.get('assets', {}) or {}
try:
timestamps: ActivityTimestamps = extra['timestamps']
except KeyError:
self._start = 0
self._end = 0
else:
self._start = timestamps.get('start', 0)
self._end = timestamps.get('end', 0)
@property
def type(self) -> ActivityType:
""":class:`ActivityType`: Returns the game's type. This is for compatibility with :class:`Activity`.
It always returns :attr:`ActivityType.playing`.
"""
return ActivityType.playing
@property
def start(self) -> Optional[datetime.datetime]:
"""Optional[:class:`datetime.datetime`]: When the user started playing this game in UTC, if applicable."""
if self._start:
return datetime.datetime.fromtimestamp(self._start / 1000, tz=datetime.timezone.utc)
return None
@property
def end(self) -> Optional[datetime.datetime]:
"""Optional[:class:`datetime.datetime`]: When the user will stop playing this game in UTC, if applicable."""
if self._end:
return datetime.datetime.fromtimestamp(self._end / 1000, tz=datetime.timezone.utc)
return None
def __str__(self) -> str:
return str(self.name)
def __repr__(self) -> str:
return f'<Game name={self.name!r} platform={self.platform!r}>'
def to_dict(self) -> Dict[str, Any]:
timestamps: Dict[str, Any] = {}
if self._start:
timestamps['start'] = self._start
if self._end:
timestamps['end'] = self._end
return {
'type': ActivityType.playing.value,
'name': str(self.name),
'timestamps': timestamps,
'platform': str(self.platform) if self.platform else None,
'assets': self.assets,
}
def __eq__(self, other: object) -> bool:
return isinstance(other, Game) and other.name == self.name
def __ne__(self, other: object) -> bool:
return not self.__eq__(other)
def __hash__(self) -> int:
return hash(self.name)
class Streaming(BaseActivity):
"""A slimmed down version of :class:`Activity` that represents a Discord streaming status.
This is typically displayed via **Streaming** on the official Discord client.
.. container:: operations
.. describe:: x == y
Checks if two streams are equal.
.. describe:: x != y
Checks if two streams are not equal.
.. describe:: hash(x)
Returns the stream's hash.
.. describe:: str(x)
Returns the stream's name.
Attributes
-----------
platform: Optional[:class:`str`]
Where the user is streaming from (ie. YouTube, Twitch).
.. versionadded:: 1.3
name: Optional[:class:`str`]
The stream's name.
details: Optional[:class:`str`]
An alias for :attr:`name`
game: Optional[:class:`str`]
The game being streamed.
.. versionadded:: 1.3
url: :class:`str`
The stream's URL.
assets: :class:`dict`
A dictionary comprising of similar keys than those in :attr:`Activity.assets`.
"""
__slots__ = ('platform', 'name', 'game', 'url', 'details', 'assets')
def __init__(self, *, name: Optional[str], url: str, **extra: Any) -> None:
super().__init__(**extra)
self.platform: Optional[str] = name
self.name: Optional[str] = extra.pop('details', name)
self.game: Optional[str] = extra.pop('state', None)
self.url: str = url
self.details: Optional[str] = extra.pop('details', self.name) # compatibility
self.assets: ActivityAssets = extra.pop('assets', {})
@property
def type(self) -> ActivityType:
""":class:`ActivityType`: Returns the game's type. This is for compatibility with :class:`Activity`.
It always returns :attr:`ActivityType.streaming`.
"""
return ActivityType.streaming
def __str__(self) -> str:
return str(self.name)
def __repr__(self) -> str:
return f'<Streaming name={self.name!r} platform={self.platform!r}>'
@property
def twitch_name(self) -> Optional[str]:
"""Optional[:class:`str`]: If provided, the twitch name of the user streaming.
This corresponds to the ``large_image`` key of the :attr:`Streaming.assets`
dictionary if it starts with ``twitch:``. Typically set by the Discord client.
"""
try:
name = self.assets['large_image'] # pyright: ignore[reportTypedDictNotRequiredAccess]
except KeyError:
return None
else:
return name[7:] if name[:7] == 'twitch:' else None
def to_dict(self) -> Dict[str, Any]:
ret: Dict[str, Any] = {
'type': ActivityType.streaming.value,
'name': str(self.name),
'url': str(self.url),
'assets': self.assets,
}
if self.details:
ret['details'] = self.details
return ret
def __eq__(self, other: object) -> bool:
return isinstance(other, Streaming) and other.name == self.name and other.url == self.url
def __ne__(self, other: object) -> bool:
return not self.__eq__(other)
def __hash__(self) -> int:
return hash(self.name)
class Spotify:
"""Represents a Spotify listening activity from Discord. This is a special case of
:class:`Activity` that makes it easier to work with the Spotify integration.
.. container:: operations
.. describe:: x == y
Checks if two activities are equal.
.. describe:: x != y
Checks if two activities are not equal.
.. describe:: hash(x)
Returns the activity's hash.
.. describe:: str(x)
Returns the string 'Spotify'.
"""
__slots__ = ('_state', '_details', '_timestamps', '_assets', '_party', '_sync_id', '_session_id', '_created_at')
def __init__(self, **data: Any) -> None:
self._state: str = data.pop('state', '')
self._details: str = data.pop('details', '')
self._timestamps: ActivityTimestamps = data.pop('timestamps', {})
self._assets: ActivityAssets = data.pop('assets', {})
self._party: ActivityParty = data.pop('party', {})
self._sync_id: str = data.pop('sync_id', '')
self._session_id: Optional[str] = data.pop('session_id')
self._created_at: Optional[float] = data.pop('created_at', None)
@property
def type(self) -> ActivityType:
""":class:`ActivityType`: Returns the activity's type. This is for compatibility with :class:`Activity`.
It always returns :attr:`ActivityType.listening`.
"""
return ActivityType.listening
@property
def created_at(self) -> Optional[datetime.datetime]:
"""Optional[:class:`datetime.datetime`]: When the user started listening in UTC.
.. versionadded:: 1.3
"""
if self._created_at is not None:
return datetime.datetime.fromtimestamp(self._created_at / 1000, tz=datetime.timezone.utc)
@property
def colour(self) -> Colour:
""":class:`Colour`: Returns the Spotify integration colour, as a :class:`Colour`.
There is an alias for this named :attr:`color`"""
return Colour(0x1DB954)
@property
def color(self) -> Colour:
""":class:`Colour`: Returns the Spotify integration colour, as a :class:`Colour`.
There is an alias for this named :attr:`colour`"""
return self.colour
def to_dict(self) -> Dict[str, Any]:
return {
'flags': 48, # SYNC | PLAY
'name': 'Spotify',
'assets': self._assets,
'party': self._party,
'sync_id': self._sync_id,
'session_id': self._session_id,
'timestamps': self._timestamps,
'details': self._details,
'state': self._state,
}
@property
def name(self) -> str:
""":class:`str`: The activity's name. This will always return "Spotify"."""
return 'Spotify'
def __eq__(self, other: object) -> bool:
return (
isinstance(other, Spotify)
and other._session_id == self._session_id
and other._sync_id == self._sync_id
and other.start == self.start
)
def __ne__(self, other: object) -> bool:
return not self.__eq__(other)
def __hash__(self) -> int:
return hash(self._session_id)
def __str__(self) -> str:
return 'Spotify'
def __repr__(self) -> str:
return f'<Spotify title={self.title!r} artist={self.artist!r} track_id={self.track_id!r}>'
@property
def title(self) -> str:
""":class:`str`: The title of the song being played."""
return self._details
@property
def artists(self) -> List[str]:
"""List[:class:`str`]: The artists of the song being played."""
return self._state.split('; ')
@property
def artist(self) -> str:
""":class:`str`: The artist of the song being played.
This does not attempt to split the artist information into
multiple artists. Useful if there's only a single artist.
"""
return self._state
@property
def album(self) -> str:
""":class:`str`: The album that the song being played belongs to."""
return self._assets.get('large_text', '')
@property
def album_cover_url(self) -> str:
""":class:`str`: The album cover image URL from Spotify's CDN."""
large_image = self._assets.get('large_image', '')
if large_image[:8] != 'spotify:':
return ''
album_image_id = large_image[8:]
return 'https://i.scdn.co/image/' + album_image_id
@property
def track_id(self) -> str:
""":class:`str`: The track ID used by Spotify to identify this song."""
return self._sync_id
@property
def track_url(self) -> str:
""":class:`str`: The track URL to listen on Spotify.
.. versionadded:: 2.0
"""
return f'https://open.spotify.com/track/{self.track_id}'
@property
def start(self) -> datetime.datetime:
""":class:`datetime.datetime`: When the user started playing this song in UTC."""
# the start key will be present here
return datetime.datetime.fromtimestamp(self._timestamps['start'] / 1000, tz=datetime.timezone.utc) # type: ignore
@property
def end(self) -> datetime.datetime:
""":class:`datetime.datetime`: When the user will stop playing this song in UTC."""
# the end key will be present here
return datetime.datetime.fromtimestamp(self._timestamps['end'] / 1000, tz=datetime.timezone.utc) # type: ignore
@property
def duration(self) -> datetime.timedelta:
""":class:`datetime.timedelta`: The duration of the song being played."""
return self.end - self.start
@property
def party_id(self) -> str:
""":class:`str`: The party ID of the listening party."""
return self._party.get('id', '')
class CustomActivity(BaseActivity):
"""Represents a custom activity from Discord.
.. container:: operations
.. describe:: x == y
Checks if two activities are equal.
.. describe:: x != y
Checks if two activities are not equal.
.. describe:: hash(x)
Returns the activity's hash.
.. describe:: str(x)
Returns the custom status text.
.. versionadded:: 1.3
Attributes
-----------
name: Optional[:class:`str`]
The custom activity's name.
emoji: Optional[:class:`PartialEmoji`]
The emoji to pass to the activity, if any.
"""
__slots__ = ('name', 'emoji', 'state')
def __init__(
self, name: Optional[str], *, emoji: Optional[Union[PartialEmoji, Dict[str, Any], str]] = None, **extra: Any
) -> None:
super().__init__(**extra)
self.name: Optional[str] = name
self.state: Optional[str] = extra.pop('state', name)
if self.name == 'Custom Status':
self.name = self.state
self.emoji: Optional[PartialEmoji]
if emoji is None:
self.emoji = emoji
elif isinstance(emoji, dict):
self.emoji = PartialEmoji.from_dict(emoji)
elif isinstance(emoji, str):
self.emoji = PartialEmoji(name=emoji)
elif isinstance(emoji, PartialEmoji):
self.emoji = emoji
else:
raise TypeError(f'Expected str, PartialEmoji, or None, received {type(emoji)!r} instead.')
@property
def type(self) -> ActivityType:
""":class:`ActivityType`: Returns the activity's type. This is for compatibility with :class:`Activity`.
It always returns :attr:`ActivityType.custom`.
"""
return ActivityType.custom
def to_dict(self) -> Dict[str, Any]:
if self.name == self.state:
o = {
'type': ActivityType.custom.value,
'state': self.name,
'name': 'Custom Status',
}
else:
o = {
'type': ActivityType.custom.value,
'name': self.name,
}
if self.emoji:
o['emoji'] = self.emoji.to_dict()
return o
def __eq__(self, other: object) -> bool:
return isinstance(other, CustomActivity) and other.name == self.name and other.emoji == self.emoji
def __ne__(self, other: object) -> bool:
return not self.__eq__(other)
def __hash__(self) -> int:
return hash((self.name, str(self.emoji)))
def __str__(self) -> str:
if self.emoji:
if self.name:
return f'{self.emoji} {self.name}'
return str(self.emoji)
else:
return str(self.name)
def __repr__(self) -> str:
return f'<CustomActivity name={self.name!r} emoji={self.emoji!r}>'
ActivityTypes = Union[Activity, Game, CustomActivity, Streaming, Spotify]
@overload
def create_activity(data: ActivityPayload, state: ConnectionState) -> ActivityTypes: ...
@overload
def create_activity(data: None, state: ConnectionState) -> None: ...
def create_activity(data: Optional[ActivityPayload], state: ConnectionState) -> Optional[ActivityTypes]:
if not data:
return None
game_type = try_enum(ActivityType, data.get('type', -1))
if game_type is ActivityType.playing:
if 'application_id' in data or 'session_id' in data:
return Activity(**data)
return Game(**data)
elif game_type is ActivityType.custom:
try:
name = data.pop('name') # type: ignore
except KeyError:
ret = Activity(**data)
else:
# we removed the name key from data already
ret = CustomActivity(name=name, **data) # type: ignore
elif game_type is ActivityType.streaming:
if 'url' in data:
# the url won't be None here
return Streaming(**data) # type: ignore
return Activity(**data)
elif game_type is ActivityType.listening and 'sync_id' in data and 'session_id' in data:
return Spotify(**data)
else:
ret = Activity(**data)
if isinstance(ret.emoji, PartialEmoji):
ret.emoji._state = state
return ret

View File

@@ -0,0 +1,21 @@
"""
discord.app_commands
~~~~~~~~~~~~~~~~~~~~~
Application commands support for the Discord API
:copyright: (c) 2015-present Rapptz
:license: MIT, see LICENSE for more details.
"""
from .commands import *
from .errors import *
from .models import *
from .tree import *
from .namespace import *
from .transformers import *
from .translator import *
from .installs import *
from . import checks as checks
from .checks import Cooldown as Cooldown

View File

@@ -0,0 +1,537 @@
"""
The MIT License (MIT)
Copyright (c) 2015-present Rapptz
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
from typing import (
Any,
Coroutine,
Dict,
Hashable,
Union,
Callable,
TypeVar,
Optional,
TYPE_CHECKING,
)
import time
from .commands import check
from .errors import (
NoPrivateMessage,
MissingRole,
MissingAnyRole,
MissingPermissions,
BotMissingPermissions,
CommandOnCooldown,
)
from ..user import User
from ..permissions import Permissions
from ..utils import get as utils_get, MISSING, maybe_coroutine
T = TypeVar('T')
if TYPE_CHECKING:
from typing_extensions import Self, Unpack
from ..interactions import Interaction
from ..permissions import _PermissionsKwargs
CooldownFunction = Union[
Callable[[Interaction[Any]], Coroutine[Any, Any, T]],
Callable[[Interaction[Any]], T],
]
__all__ = (
'has_role',
'has_any_role',
'has_permissions',
'bot_has_permissions',
'cooldown',
'dynamic_cooldown',
)
class Cooldown:
"""Represents a cooldown for a command.
.. versionadded:: 2.0
Attributes
-----------
rate: :class:`float`
The total number of tokens available per :attr:`per` seconds.
per: :class:`float`
The length of the cooldown period in seconds.
"""
__slots__ = ('rate', 'per', '_window', '_tokens', '_last')
def __init__(self, rate: float, per: float) -> None:
self.rate: int = int(rate)
self.per: float = float(per)
self._window: float = 0.0
self._tokens: int = self.rate
self._last: float = 0.0
def get_tokens(self, current: Optional[float] = None) -> int:
"""Returns the number of available tokens before rate limiting is applied.
Parameters
------------
current: Optional[:class:`float`]
The time in seconds since Unix epoch to calculate tokens at.
If not supplied then :func:`time.time()` is used.
Returns
--------
:class:`int`
The number of tokens available before the cooldown is to be applied.
"""
if not current:
current = time.time()
# the calculated tokens should be non-negative
tokens = max(self._tokens, 0)
if current > self._window + self.per:
tokens = self.rate
return tokens
def get_retry_after(self, current: Optional[float] = None) -> float:
"""Returns the time in seconds until the cooldown will be reset.
Parameters
-------------
current: Optional[:class:`float`]
The current time in seconds since Unix epoch.
If not supplied, then :func:`time.time()` is used.
Returns
-------
:class:`float`
The number of seconds to wait before this cooldown will be reset.
"""
current = current or time.time()
tokens = self.get_tokens(current)
if tokens == 0:
return self.per - (current - self._window)
return 0.0
def update_rate_limit(self, current: Optional[float] = None, *, tokens: int = 1) -> Optional[float]:
"""Updates the cooldown rate limit.
Parameters
-------------
current: Optional[:class:`float`]
The time in seconds since Unix epoch to update the rate limit at.
If not supplied, then :func:`time.time()` is used.
tokens: :class:`int`
The amount of tokens to deduct from the rate limit.
Returns
-------
Optional[:class:`float`]
The retry-after time in seconds if rate limited.
"""
current = current or time.time()
self._last = current
self._tokens = self.get_tokens(current)
# first token used means that we start a new rate limit window
if self._tokens == self.rate:
self._window = current
# decrement tokens by specified number
self._tokens -= tokens
# check if we are rate limited and return retry-after
if self._tokens < 0:
return self.per - (current - self._window)
def reset(self) -> None:
"""Reset the cooldown to its initial state."""
self._tokens = self.rate
self._last = 0.0
def copy(self) -> Self:
"""Creates a copy of this cooldown.
Returns
--------
:class:`Cooldown`
A new instance of this cooldown.
"""
return self.__class__(self.rate, self.per)
def __repr__(self) -> str:
return f'<Cooldown rate: {self.rate} per: {self.per} window: {self._window} tokens: {self._tokens}>'
def has_role(item: Union[int, str], /) -> Callable[[T], T]:
"""A :func:`~discord.app_commands.check` that is added that checks if the member invoking the
command has the role specified via the name or ID specified.
If a string is specified, you must give the exact name of the role, including
caps and spelling.
If an integer is specified, you must give the exact snowflake ID of the role.
This check raises one of two special exceptions, :exc:`~discord.app_commands.MissingRole`
if the user is missing a role, or :exc:`~discord.app_commands.NoPrivateMessage` if
it is used in a private message. Both inherit from :exc:`~discord.app_commands.CheckFailure`.
.. versionadded:: 2.0
.. note::
This is different from the permission system that Discord provides for application
commands. This is done entirely locally in the program rather than being handled
by Discord.
Parameters
-----------
item: Union[:class:`int`, :class:`str`]
The name or ID of the role to check.
"""
def predicate(interaction: Interaction) -> bool:
if isinstance(interaction.user, User):
raise NoPrivateMessage()
if isinstance(item, int):
role = interaction.user.get_role(item)
else:
role = utils_get(interaction.user.roles, name=item)
if role is None:
raise MissingRole(item)
return True
return check(predicate)
def has_any_role(*items: Union[int, str]) -> Callable[[T], T]:
r"""A :func:`~discord.app_commands.check` that is added that checks if the member
invoking the command has **any** of the roles specified. This means that if they have
one out of the three roles specified, then this check will return ``True``.
Similar to :func:`has_role`\, the names or IDs passed in must be exact.
This check raises one of two special exceptions, :exc:`~discord.app_commands.MissingAnyRole`
if the user is missing all roles, or :exc:`~discord.app_commands.NoPrivateMessage` if
it is used in a private message. Both inherit from :exc:`~discord.app_commands.CheckFailure`.
.. versionadded:: 2.0
.. note::
This is different from the permission system that Discord provides for application
commands. This is done entirely locally in the program rather than being handled
by Discord.
Parameters
-----------
items: List[Union[:class:`str`, :class:`int`]]
An argument list of names or IDs to check that the member has roles wise.
Example
--------
.. code-block:: python3
@tree.command()
@app_commands.checks.has_any_role('Library Devs', 'Moderators', 492212595072434186)
async def cool(interaction: discord.Interaction):
await interaction.response.send_message('You are cool indeed')
"""
def predicate(interaction: Interaction) -> bool:
if isinstance(interaction.user, User):
raise NoPrivateMessage()
if any(
interaction.user.get_role(item) is not None
if isinstance(item, int)
else utils_get(interaction.user.roles, name=item) is not None
for item in items
):
return True
raise MissingAnyRole(list(items))
return check(predicate)
def has_permissions(**perms: Unpack[_PermissionsKwargs]) -> Callable[[T], T]:
r"""A :func:`~discord.app_commands.check` that is added that checks if the member
has all of the permissions necessary.
Note that this check operates on the permissions given by
:attr:`discord.Interaction.permissions`.
The permissions passed in must be exactly like the properties shown under
:class:`discord.Permissions`.
This check raises a special exception, :exc:`~discord.app_commands.MissingPermissions`
that is inherited from :exc:`~discord.app_commands.CheckFailure`.
.. versionadded:: 2.0
.. note::
This is different from the permission system that Discord provides for application
commands. This is done entirely locally in the program rather than being handled
by Discord.
Parameters
------------
\*\*perms: :class:`bool`
Keyword arguments denoting the permissions to check for.
Example
---------
.. code-block:: python3
@tree.command()
@app_commands.checks.has_permissions(manage_messages=True)
async def test(interaction: discord.Interaction):
await interaction.response.send_message('You can manage messages.')
"""
invalid = perms.keys() - Permissions.VALID_FLAGS.keys()
if invalid:
raise TypeError(f'Invalid permission(s): {", ".join(invalid)}')
def predicate(interaction: Interaction) -> bool:
permissions = interaction.permissions
missing = [perm for perm, value in perms.items() if getattr(permissions, perm) != value]
if not missing:
return True
raise MissingPermissions(missing)
return check(predicate)
def bot_has_permissions(**perms: Unpack[_PermissionsKwargs]) -> Callable[[T], T]:
"""Similar to :func:`has_permissions` except checks if the bot itself has
the permissions listed. This relies on :attr:`discord.Interaction.app_permissions`.
This check raises a special exception, :exc:`~discord.app_commands.BotMissingPermissions`
that is inherited from :exc:`~discord.app_commands.CheckFailure`.
.. versionadded:: 2.0
"""
invalid = set(perms) - set(Permissions.VALID_FLAGS)
if invalid:
raise TypeError(f'Invalid permission(s): {", ".join(invalid)}')
def predicate(interaction: Interaction) -> bool:
permissions = interaction.app_permissions
missing = [perm for perm, value in perms.items() if getattr(permissions, perm) != value]
if not missing:
return True
raise BotMissingPermissions(missing)
return check(predicate)
def _create_cooldown_decorator(
key: CooldownFunction[Hashable], factory: CooldownFunction[Optional[Cooldown]]
) -> Callable[[T], T]:
mapping: Dict[Any, Cooldown] = {}
async def get_bucket(
interaction: Interaction,
*,
mapping: Dict[Any, Cooldown] = mapping,
key: CooldownFunction[Hashable] = key,
factory: CooldownFunction[Optional[Cooldown]] = factory,
) -> Optional[Cooldown]:
current = interaction.created_at.timestamp()
dead_keys = [k for k, v in mapping.items() if current > v._last + v.per]
for k in dead_keys:
del mapping[k]
k = await maybe_coroutine(key, interaction)
if k not in mapping:
bucket: Optional[Cooldown] = await maybe_coroutine(factory, interaction)
if bucket is not None:
mapping[k] = bucket
else:
bucket = mapping[k]
return bucket
async def predicate(interaction: Interaction) -> bool:
bucket = await get_bucket(interaction)
if bucket is None:
return True
retry_after = bucket.update_rate_limit(interaction.created_at.timestamp())
if retry_after is None:
return True
raise CommandOnCooldown(bucket, retry_after)
return check(predicate)
def cooldown(
rate: float,
per: float,
*,
key: Optional[CooldownFunction[Hashable]] = MISSING,
) -> Callable[[T], T]:
"""A decorator that adds a cooldown to a command.
A cooldown allows a command to only be used a specific amount
of times in a specific time frame. These cooldowns are based off
of the ``key`` function provided. If a ``key`` is not provided
then it defaults to a user-level cooldown. The ``key`` function
must take a single parameter, the :class:`discord.Interaction` and
return a value that is used as a key to the internal cooldown mapping.
The ``key`` function can optionally be a coroutine.
If a cooldown is triggered, then :exc:`~discord.app_commands.CommandOnCooldown` is
raised to the error handlers.
Examples
---------
Setting a one per 5 seconds per member cooldown on a command:
.. code-block:: python3
@tree.command()
@app_commands.checks.cooldown(1, 5.0, key=lambda i: (i.guild_id, i.user.id))
async def test(interaction: discord.Interaction):
await interaction.response.send_message('Hello')
@test.error
async def on_test_error(interaction: discord.Interaction, error: app_commands.AppCommandError):
if isinstance(error, app_commands.CommandOnCooldown):
await interaction.response.send_message(str(error), ephemeral=True)
Parameters
------------
rate: :class:`int`
The number of times a command can be used before triggering a cooldown.
per: :class:`float`
The amount of seconds to wait for a cooldown when it's been triggered.
key: Optional[Callable[[:class:`discord.Interaction`], :class:`collections.abc.Hashable`]]
A function that returns a key to the mapping denoting the type of cooldown.
Can optionally be a coroutine. If not given then defaults to a user-level
cooldown. If ``None`` is passed then it is interpreted as a "global" cooldown.
"""
if key is MISSING:
key_func = lambda interaction: interaction.user.id
elif key is None:
key_func = lambda i: None
else:
key_func = key
factory = lambda interaction: Cooldown(rate, per)
return _create_cooldown_decorator(key_func, factory)
def dynamic_cooldown(
factory: CooldownFunction[Optional[Cooldown]],
*,
key: Optional[CooldownFunction[Hashable]] = MISSING,
) -> Callable[[T], T]:
"""A decorator that adds a dynamic cooldown to a command.
A cooldown allows a command to only be used a specific amount
of times in a specific time frame. These cooldowns are based off
of the ``key`` function provided. If a ``key`` is not provided
then it defaults to a user-level cooldown. The ``key`` function
must take a single parameter, the :class:`discord.Interaction` and
return a value that is used as a key to the internal cooldown mapping.
If a ``factory`` function is given, it must be a function that
accepts a single parameter of type :class:`discord.Interaction` and must
return a :class:`~discord.app_commands.Cooldown` or ``None``.
If ``None`` is returned then that cooldown is effectively bypassed.
Both ``key`` and ``factory`` can optionally be coroutines.
If a cooldown is triggered, then :exc:`~discord.app_commands.CommandOnCooldown` is
raised to the error handlers.
Examples
---------
Setting a cooldown for everyone but the owner.
.. code-block:: python3
def cooldown_for_everyone_but_me(interaction: discord.Interaction) -> Optional[app_commands.Cooldown]:
if interaction.user.id == 80088516616269824:
return None
return app_commands.Cooldown(1, 10.0)
@tree.command()
@app_commands.checks.dynamic_cooldown(cooldown_for_everyone_but_me)
async def test(interaction: discord.Interaction):
await interaction.response.send_message('Hello')
@test.error
async def on_test_error(interaction: discord.Interaction, error: app_commands.AppCommandError):
if isinstance(error, app_commands.CommandOnCooldown):
await interaction.response.send_message(str(error), ephemeral=True)
Parameters
------------
factory: Optional[Callable[[:class:`discord.Interaction`], Optional[:class:`~discord.app_commands.Cooldown`]]]
A function that takes an interaction and returns a cooldown that will apply to that interaction
or ``None`` if the interaction should not have a cooldown.
key: Optional[Callable[[:class:`discord.Interaction`], :class:`collections.abc.Hashable`]]
A function that returns a key to the mapping denoting the type of cooldown.
Can optionally be a coroutine. If not given then defaults to a user-level
cooldown. If ``None`` is passed then it is interpreted as a "global" cooldown.
"""
if key is MISSING:
key_func = lambda interaction: interaction.user.id
elif key is None:
key_func = lambda i: None
else:
key_func = key
return _create_cooldown_decorator(key_func, factory)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,519 @@
"""
The MIT License (MIT)
Copyright (c) 2015-present Rapptz
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
from typing import Any, TYPE_CHECKING, List, Optional, Sequence, Union
from ..enums import AppCommandOptionType, AppCommandType, Locale
from ..errors import DiscordException, HTTPException, _flatten_error_dict, MissingApplicationID as MissingApplicationID
from ..utils import _human_join
__all__ = (
'AppCommandError',
'CommandInvokeError',
'TransformerError',
'TranslationError',
'CheckFailure',
'CommandAlreadyRegistered',
'CommandSignatureMismatch',
'CommandNotFound',
'CommandLimitReached',
'NoPrivateMessage',
'MissingRole',
'MissingAnyRole',
'MissingPermissions',
'BotMissingPermissions',
'CommandOnCooldown',
'MissingApplicationID',
'CommandSyncFailure',
)
if TYPE_CHECKING:
from .commands import Command, Group, ContextMenu, Parameter
from .transformers import Transformer
from .translator import TranslationContextTypes, locale_str
from ..types.snowflake import Snowflake, SnowflakeList
from .checks import Cooldown
CommandTypes = Union[Command[Any, ..., Any], Group, ContextMenu]
class AppCommandError(DiscordException):
"""The base exception type for all application command related errors.
This inherits from :exc:`discord.DiscordException`.
This exception and exceptions inherited from it are handled
in a special way as they are caught and passed into various error handlers
in this order:
- :meth:`Command.error <discord.app_commands.Command.error>`
- :meth:`Group.on_error <discord.app_commands.Group.on_error>`
- :meth:`CommandTree.on_error <discord.app_commands.CommandTree.on_error>`
.. versionadded:: 2.0
"""
pass
class CommandInvokeError(AppCommandError):
"""An exception raised when the command being invoked raised an exception.
This inherits from :exc:`~discord.app_commands.AppCommandError`.
.. versionadded:: 2.0
Attributes
-----------
original: :exc:`Exception`
The original exception that was raised. You can also get this via
the ``__cause__`` attribute.
command: Union[:class:`Command`, :class:`ContextMenu`]
The command that failed.
"""
def __init__(self, command: Union[Command[Any, ..., Any], ContextMenu], e: Exception) -> None:
self.original: Exception = e
self.command: Union[Command[Any, ..., Any], ContextMenu] = command
super().__init__(f'Command {command.name!r} raised an exception: {e.__class__.__name__}: {e}')
class TransformerError(AppCommandError):
"""An exception raised when a :class:`Transformer` or type annotation fails to
convert to its target type.
This inherits from :exc:`~discord.app_commands.AppCommandError`.
If an exception occurs while converting that does not subclass
:exc:`AppCommandError` then the exception is wrapped into this exception.
The original exception can be retrieved using the ``__cause__`` attribute.
Otherwise if the exception derives from :exc:`AppCommandError` then it will
be propagated as-is.
.. versionadded:: 2.0
Attributes
-----------
value: Any
The value that failed to convert.
type: :class:`~discord.AppCommandOptionType`
The type of argument that failed to convert.
transformer: :class:`Transformer`
The transformer that failed the conversion.
"""
def __init__(self, value: Any, opt_type: AppCommandOptionType, transformer: Transformer):
self.value: Any = value
self.type: AppCommandOptionType = opt_type
self.transformer: Transformer = transformer
super().__init__(f'Failed to convert {value} to {transformer._error_display_name!s}')
class TranslationError(AppCommandError):
"""An exception raised when the library fails to translate a string.
This inherits from :exc:`~discord.app_commands.AppCommandError`.
If an exception occurs while calling :meth:`Translator.translate` that does
not subclass this then the exception is wrapped into this exception.
The original exception can be retrieved using the ``__cause__`` attribute.
Otherwise it will be propagated as-is.
.. versionadded:: 2.0
Attributes
-----------
string: Optional[Union[:class:`str`, :class:`locale_str`]]
The string that caused the error, if any.
locale: Optional[:class:`~discord.Locale`]
The locale that caused the error, if any.
context: :class:`~discord.app_commands.TranslationContext`
The context of the translation that triggered the error.
"""
def __init__(
self,
*msg: str,
string: Optional[Union[str, locale_str]] = None,
locale: Optional[Locale] = None,
context: TranslationContextTypes,
) -> None:
self.string: Optional[Union[str, locale_str]] = string
self.locale: Optional[Locale] = locale
self.context: TranslationContextTypes = context
if msg:
super().__init__(*msg)
else:
ctx = context.location.name.replace('_', ' ')
fmt = f'Failed to translate {self.string!r} in a {ctx}'
if self.locale is not None:
fmt = f'{fmt} in the {self.locale.value} locale'
super().__init__(fmt)
class CheckFailure(AppCommandError):
"""An exception raised when check predicates in a command have failed.
This inherits from :exc:`~discord.app_commands.AppCommandError`.
.. versionadded:: 2.0
"""
pass
class NoPrivateMessage(CheckFailure):
"""An exception raised when a command does not work in a direct message.
This inherits from :exc:`~discord.app_commands.CheckFailure`.
.. versionadded:: 2.0
"""
def __init__(self, message: Optional[str] = None) -> None:
super().__init__(message or 'This command cannot be used in direct messages.')
class MissingRole(CheckFailure):
"""An exception raised when the command invoker lacks a role to run a command.
This inherits from :exc:`~discord.app_commands.CheckFailure`.
.. versionadded:: 2.0
Attributes
-----------
missing_role: Union[:class:`str`, :class:`int`]
The required role that is missing.
This is the parameter passed to :func:`~discord.app_commands.checks.has_role`.
"""
def __init__(self, missing_role: Snowflake) -> None:
self.missing_role: Snowflake = missing_role
message = f'Role {missing_role!r} is required to run this command.'
super().__init__(message)
class MissingAnyRole(CheckFailure):
"""An exception raised when the command invoker lacks any of the roles
specified to run a command.
This inherits from :exc:`~discord.app_commands.CheckFailure`.
.. versionadded:: 2.0
Attributes
-----------
missing_roles: List[Union[:class:`str`, :class:`int`]]
The roles that the invoker is missing.
These are the parameters passed to :func:`~discord.app_commands.checks.has_any_role`.
"""
def __init__(self, missing_roles: SnowflakeList) -> None:
self.missing_roles: SnowflakeList = missing_roles
fmt = _human_join([f"'{role}'" for role in missing_roles])
message = f'You are missing at least one of the required roles: {fmt}'
super().__init__(message)
class MissingPermissions(CheckFailure):
"""An exception raised when the command invoker lacks permissions to run a
command.
This inherits from :exc:`~discord.app_commands.CheckFailure`.
.. versionadded:: 2.0
Attributes
-----------
missing_permissions: List[:class:`str`]
The required permissions that are missing.
"""
def __init__(self, missing_permissions: List[str], *args: Any) -> None:
self.missing_permissions: List[str] = missing_permissions
missing = [perm.replace('_', ' ').replace('guild', 'server').title() for perm in missing_permissions]
fmt = _human_join(missing, final='and')
message = f'You are missing {fmt} permission(s) to run this command.'
super().__init__(message, *args)
class BotMissingPermissions(CheckFailure):
"""An exception raised when the bot's member lacks permissions to run a
command.
This inherits from :exc:`~discord.app_commands.CheckFailure`.
.. versionadded:: 2.0
Attributes
-----------
missing_permissions: List[:class:`str`]
The required permissions that are missing.
"""
def __init__(self, missing_permissions: List[str], *args: Any) -> None:
self.missing_permissions: List[str] = missing_permissions
missing = [perm.replace('_', ' ').replace('guild', 'server').title() for perm in missing_permissions]
fmt = _human_join(missing, final='and')
message = f'Bot requires {fmt} permission(s) to run this command.'
super().__init__(message, *args)
class CommandOnCooldown(CheckFailure):
"""An exception raised when the command being invoked is on cooldown.
This inherits from :exc:`~discord.app_commands.CheckFailure`.
.. versionadded:: 2.0
Attributes
-----------
cooldown: :class:`~discord.app_commands.Cooldown`
The cooldown that was triggered.
retry_after: :class:`float`
The amount of seconds to wait before you can retry again.
"""
def __init__(self, cooldown: Cooldown, retry_after: float) -> None:
self.cooldown: Cooldown = cooldown
self.retry_after: float = retry_after
super().__init__(f'You are on cooldown. Try again in {retry_after:.2f}s')
class CommandAlreadyRegistered(AppCommandError):
"""An exception raised when a command is already registered.
This inherits from :exc:`~discord.app_commands.AppCommandError`.
.. versionadded:: 2.0
Attributes
-----------
name: :class:`str`
The name of the command already registered.
guild_id: Optional[:class:`int`]
The guild ID this command was already registered at.
If ``None`` then it was a global command.
"""
def __init__(self, name: str, guild_id: Optional[int]):
self.name: str = name
self.guild_id: Optional[int] = guild_id
super().__init__(f'Command {name!r} already registered.')
class CommandNotFound(AppCommandError):
"""An exception raised when an application command could not be found.
This inherits from :exc:`~discord.app_commands.AppCommandError`.
.. versionadded:: 2.0
Attributes
------------
name: :class:`str`
The name of the application command not found.
parents: List[:class:`str`]
A list of parent command names that were previously found
prior to the application command not being found.
type: :class:`~discord.AppCommandType`
The type of command that was not found.
"""
def __init__(self, name: str, parents: List[str], type: AppCommandType = AppCommandType.chat_input):
self.name: str = name
self.parents: List[str] = parents
self.type: AppCommandType = type
super().__init__(f'Application command {name!r} not found')
class CommandLimitReached(AppCommandError):
"""An exception raised when the maximum number of application commands was reached
either globally or in a guild.
This inherits from :exc:`~discord.app_commands.AppCommandError`.
.. versionadded:: 2.0
Attributes
------------
type: :class:`~discord.AppCommandType`
The type of command that reached the limit.
guild_id: Optional[:class:`int`]
The guild ID that reached the limit or ``None`` if it was global.
limit: :class:`int`
The limit that was hit.
"""
def __init__(self, guild_id: Optional[int], limit: int, type: AppCommandType = AppCommandType.chat_input):
self.guild_id: Optional[int] = guild_id
self.limit: int = limit
self.type: AppCommandType = type
lookup = {
AppCommandType.chat_input: 'slash commands',
AppCommandType.message: 'message context menu commands',
AppCommandType.user: 'user context menu commands',
}
desc = lookup.get(type, 'application commands')
ns = 'globally' if self.guild_id is None else f'for guild ID {self.guild_id}'
super().__init__(f'maximum number of {desc} exceeded {limit} {ns}')
class CommandSignatureMismatch(AppCommandError):
"""An exception raised when an application command from Discord has a different signature
from the one provided in the code. This happens because your command definition differs
from the command definition you provided Discord. Either your code is out of date or the
data from Discord is out of sync.
This inherits from :exc:`~discord.app_commands.AppCommandError`.
.. versionadded:: 2.0
Attributes
------------
command: Union[:class:`~.app_commands.Command`, :class:`~.app_commands.ContextMenu`, :class:`~.app_commands.Group`]
The command that had the signature mismatch.
"""
def __init__(self, command: Union[Command[Any, ..., Any], ContextMenu, Group]):
self.command: Union[Command[Any, ..., Any], ContextMenu, Group] = command
msg = (
f'The signature for command {command.name!r} is different from the one provided by Discord. '
'This can happen because either your code is out of date or you have not synced the '
'commands with Discord, causing the mismatch in data. It is recommended to sync the '
'command tree to fix this issue.'
)
super().__init__(msg)
def _get_command_error(
index: str,
inner: Any,
objects: Sequence[Union[Parameter, CommandTypes]],
messages: List[str],
indent: int = 0,
) -> None:
# Import these here to avoid circular imports
from .commands import Command, Group, ContextMenu
indentation = ' ' * indent
# Top level errors are:
# <number>: { <key>: <error> }
# The dicts could be nested, e.g.
# <number>: { <key>: { <second>: <error> } }
# Luckily, this is already handled by the flatten_error_dict utility
if not index.isdigit():
errors = _flatten_error_dict(inner, index)
messages.extend(f'In {k}: {v}' for k, v in errors.items())
return
idx = int(index)
try:
obj = objects[idx]
except IndexError:
dedent_one_level = ' ' * (indent - 2)
errors = _flatten_error_dict(inner, index)
messages.extend(f'{dedent_one_level}In {k}: {v}' for k, v in errors.items())
return
children: Sequence[Union[Parameter, CommandTypes]] = []
if isinstance(obj, Command):
messages.append(f'{indentation}In command {obj.qualified_name!r} defined in function {obj.callback.__qualname__!r}')
children = obj.parameters
elif isinstance(obj, Group):
messages.append(f'{indentation}In group {obj.qualified_name!r} defined in module {obj.module!r}')
children = obj.commands
elif isinstance(obj, ContextMenu):
messages.append(
f'{indentation}In context menu {obj.qualified_name!r} defined in function {obj.callback.__qualname__!r}'
)
else:
messages.append(f'{indentation}In parameter {obj.name!r}')
for key, remaining in inner.items():
# Special case the 'options' key since they have well defined meanings
if key == 'options':
for index, d in remaining.items():
_get_command_error(index, d, children, messages, indent=indent + 2)
elif key == '_errors':
errors = [x.get('message', '') for x in remaining]
messages.extend(f'{indentation} {message}' for message in errors)
else:
if isinstance(remaining, dict):
try:
inner_errors = remaining['_errors']
except KeyError:
errors = _flatten_error_dict(remaining, key=key)
else:
errors = {key: ' '.join(x.get('message', '') for x in inner_errors)}
if isinstance(errors, dict):
messages.extend(f'{indentation} {k}: {v}' for k, v in errors.items())
class CommandSyncFailure(AppCommandError, HTTPException):
"""An exception raised when :meth:`CommandTree.sync` failed.
This provides syncing failures in a slightly more readable format.
This inherits from :exc:`~discord.app_commands.AppCommandError`
and :exc:`~discord.HTTPException`.
.. versionadded:: 2.0
"""
def __init__(self, child: HTTPException, commands: List[CommandTypes]) -> None:
# Consume the child exception and make it seem as if we are that exception
self.__dict__.update(child.__dict__)
messages = [f'Failed to upload commands to Discord (HTTP status {self.status}, error code {self.code})']
if self._errors:
# Handle case where the errors dict has no actual chain such as APPLICATION_COMMAND_TOO_LARGE
if len(self._errors) == 1 and '_errors' in self._errors:
errors = self._errors['_errors']
if len(errors) == 1:
extra = errors[0].get('message')
if extra:
messages[0] += f': {extra}'
else:
messages.extend(f'Error {e.get("code", "")}: {e.get("message", "")}' for e in errors)
else:
for index, inner in self._errors.items():
_get_command_error(index, inner, commands, messages)
# Equivalent to super().__init__(...) but skips other constructors
self.args = ('\n'.join(messages),)

View File

@@ -0,0 +1,213 @@
"""
The MIT License (MIT)
Copyright (c) 2015-present Rapptz
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar, List, Optional, Sequence
__all__ = (
'AppInstallationType',
'AppCommandContext',
)
if TYPE_CHECKING:
from typing_extensions import Self
from ..types.interactions import InteractionContextType, InteractionInstallationType
class AppInstallationType:
r"""Represents the installation location of an application command.
.. versionadded:: 2.4
Parameters
-----------
guild: Optional[:class:`bool`]
Whether the integration is a guild install.
user: Optional[:class:`bool`]
Whether the integration is a user install.
"""
__slots__ = ('_guild', '_user')
GUILD: ClassVar[int] = 0
USER: ClassVar[int] = 1
def __init__(self, *, guild: Optional[bool] = None, user: Optional[bool] = None):
self._guild: Optional[bool] = guild
self._user: Optional[bool] = user
def __repr__(self):
return f'<AppInstallationType guild={self.guild!r} user={self.user!r}>'
@property
def guild(self) -> bool:
""":class:`bool`: Whether the integration is a guild install."""
return bool(self._guild)
@guild.setter
def guild(self, value: bool) -> None:
self._guild = bool(value)
@property
def user(self) -> bool:
""":class:`bool`: Whether the integration is a user install."""
return bool(self._user)
@user.setter
def user(self, value: bool) -> None:
self._user = bool(value)
def merge(self, other: AppInstallationType) -> AppInstallationType:
# Merging is similar to AllowedMentions where `self` is the base
# and the `other` is the override preference
guild = self._guild if other._guild is None else other._guild
user = self._user if other._user is None else other._user
return AppInstallationType(guild=guild, user=user)
def _is_unset(self) -> bool:
return all(x is None for x in (self._guild, self._user))
def _merge_to_array(self, other: Optional[AppInstallationType]) -> Optional[List[InteractionInstallationType]]:
result = self.merge(other) if other is not None else self
if result._is_unset():
return None
return result.to_array()
@classmethod
def _from_value(cls, value: Sequence[InteractionInstallationType]) -> Self:
self = cls()
for x in value:
if x == cls.GUILD:
self._guild = True
elif x == cls.USER:
self._user = True
return self
def to_array(self) -> List[InteractionInstallationType]:
values = []
if self._guild:
values.append(self.GUILD)
if self._user:
values.append(self.USER)
return values
class AppCommandContext:
r"""Wraps up the Discord :class:`~discord.app_commands.Command` execution context.
.. versionadded:: 2.4
Parameters
-----------
guild: Optional[:class:`bool`]
Whether the context allows usage in a guild.
dm_channel: Optional[:class:`bool`]
Whether the context allows usage in a DM channel.
private_channel: Optional[:class:`bool`]
Whether the context allows usage in a DM or a GDM channel.
"""
GUILD: ClassVar[int] = 0
DM_CHANNEL: ClassVar[int] = 1
PRIVATE_CHANNEL: ClassVar[int] = 2
__slots__ = ('_guild', '_dm_channel', '_private_channel')
def __init__(
self,
*,
guild: Optional[bool] = None,
dm_channel: Optional[bool] = None,
private_channel: Optional[bool] = None,
):
self._guild: Optional[bool] = guild
self._dm_channel: Optional[bool] = dm_channel
self._private_channel: Optional[bool] = private_channel
def __repr__(self) -> str:
return f'<AppCommandContext guild={self.guild!r} dm_channel={self.dm_channel!r} private_channel={self.private_channel!r}>'
@property
def guild(self) -> bool:
""":class:`bool`: Whether the context allows usage in a guild."""
return bool(self._guild)
@guild.setter
def guild(self, value: bool) -> None:
self._guild = bool(value)
@property
def dm_channel(self) -> bool:
""":class:`bool`: Whether the context allows usage in a DM channel."""
return bool(self._dm_channel)
@dm_channel.setter
def dm_channel(self, value: bool) -> None:
self._dm_channel = bool(value)
@property
def private_channel(self) -> bool:
""":class:`bool`: Whether the context allows usage in a DM or a GDM channel."""
return bool(self._private_channel)
@private_channel.setter
def private_channel(self, value: bool) -> None:
self._private_channel = bool(value)
def merge(self, other: AppCommandContext) -> AppCommandContext:
guild = self._guild if other._guild is None else other._guild
dm_channel = self._dm_channel if other._dm_channel is None else other._dm_channel
private_channel = self._private_channel if other._private_channel is None else other._private_channel
return AppCommandContext(guild=guild, dm_channel=dm_channel, private_channel=private_channel)
def _is_unset(self) -> bool:
return all(x is None for x in (self._guild, self._dm_channel, self._private_channel))
def _merge_to_array(self, other: Optional[AppCommandContext]) -> Optional[List[InteractionContextType]]:
result = self.merge(other) if other is not None else self
if result._is_unset():
return None
return result.to_array()
@classmethod
def _from_value(cls, value: Sequence[InteractionContextType]) -> Self:
self = cls()
for x in value:
if x == cls.GUILD:
self._guild = True
elif x == cls.DM_CHANNEL:
self._dm_channel = True
elif x == cls.PRIVATE_CHANNEL:
self._private_channel = True
return self
def to_array(self) -> List[InteractionContextType]:
values = []
if self._guild:
values.append(self.GUILD)
if self._dm_channel:
values.append(self.DM_CHANNEL)
if self._private_channel:
values.append(self.PRIVATE_CHANNEL)
return values

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,263 @@
"""
The MIT License (MIT)
Copyright (c) 2015-present Rapptz
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Dict, Iterable, Iterator, List, NamedTuple, Tuple
from ..member import Member
from ..object import Object
from ..role import Role
from ..message import Message, Attachment
from ..channel import PartialMessageable
from ..enums import AppCommandOptionType
from .models import AppCommandChannel, AppCommandThread
if TYPE_CHECKING:
from ..interactions import Interaction
from ..types.interactions import ResolvedData, ApplicationCommandInteractionDataOption
__all__ = ('Namespace',)
class ResolveKey(NamedTuple):
id: str
# CommandOptionType does not use 0 or negative numbers so those can be safe for library
# internal use, if necessary. Likewise, only 6, 7, 8, and 11 are actually in use.
type: int
@classmethod
def any_with(cls, id: str) -> ResolveKey:
return ResolveKey(id=id, type=-1)
def __eq__(self, o: object) -> bool:
if not isinstance(o, ResolveKey):
return NotImplemented
if self.type == -1 or o.type == -1:
return self.id == o.id
return (self.id, self.type) == (o.id, o.type)
def __hash__(self) -> int:
# Most of the time an ID lookup is all that is necessary
# In case of collision then we look up both the ID and the type.
return hash(self.id)
class Namespace:
"""An object that holds the parameters being passed to a command in a mostly raw state.
This class is deliberately simple and just holds the option name and resolved value as a simple
key-pair mapping. These attributes can be accessed using dot notation. For example, an option
with the name of ``example`` can be accessed using ``ns.example``. If an attribute is not found,
then ``None`` is returned rather than an attribute error.
.. warning::
The key names come from the raw Discord data, which means that if a parameter was renamed then the
renamed key is used instead of the function parameter name.
.. versionadded:: 2.0
.. container:: operations
.. describe:: x == y
Checks if two namespaces are equal by checking if all attributes are equal.
.. describe:: x != y
Checks if two namespaces are not equal.
.. describe:: x[key]
Returns an attribute if it is found, otherwise raises
a :exc:`KeyError`.
.. describe:: key in x
Checks if the attribute is in the namespace.
.. describe:: iter(x)
Returns an iterator of ``(name, value)`` pairs. This allows it
to be, for example, constructed as a dict or a list of pairs.
This namespace object converts resolved objects into their appropriate form depending on their
type. Consult the table below for conversion information.
+-------------------------------------------+-------------------------------------------------------------------------------+
| Option Type | Resolved Type |
+===========================================+===============================================================================+
| :attr:`.AppCommandOptionType.string` | :class:`str` |
+-------------------------------------------+-------------------------------------------------------------------------------+
| :attr:`.AppCommandOptionType.integer` | :class:`int` |
+-------------------------------------------+-------------------------------------------------------------------------------+
| :attr:`.AppCommandOptionType.boolean` | :class:`bool` |
+-------------------------------------------+-------------------------------------------------------------------------------+
| :attr:`.AppCommandOptionType.number` | :class:`float` |
+-------------------------------------------+-------------------------------------------------------------------------------+
| :attr:`.AppCommandOptionType.user` | :class:`~discord.User` or :class:`~discord.Member` |
+-------------------------------------------+-------------------------------------------------------------------------------+
| :attr:`.AppCommandOptionType.channel` | :class:`.AppCommandChannel` or :class:`.AppCommandThread` |
+-------------------------------------------+-------------------------------------------------------------------------------+
| :attr:`.AppCommandOptionType.role` | :class:`~discord.Role` |
+-------------------------------------------+-------------------------------------------------------------------------------+
| :attr:`.AppCommandOptionType.mentionable` | :class:`~discord.User` or :class:`~discord.Member`, or :class:`~discord.Role` |
+-------------------------------------------+-------------------------------------------------------------------------------+
| :attr:`.AppCommandOptionType.attachment` | :class:`~discord.Attachment` |
+-------------------------------------------+-------------------------------------------------------------------------------+
.. note::
In autocomplete interactions, the namespace might not be validated or filled in. Discord does not
send the resolved data as well, so this means that certain fields end up just as IDs rather than
the resolved data. In these cases, a :class:`discord.Object` is returned instead.
This is a Discord limitation.
"""
def __init__(
self,
interaction: Interaction,
resolved: ResolvedData,
options: List[ApplicationCommandInteractionDataOption],
):
completed = self._get_resolved_items(interaction, resolved)
for option in options:
opt_type = option['type']
name = option['name']
focused = option.get('focused', False)
if opt_type in (3, 4, 5): # string, integer, boolean
value = option['value'] # type: ignore # Key is there
self.__dict__[name] = value
elif opt_type == 10: # number
value = option['value'] # type: ignore # Key is there
# This condition is written this way because 0 can be a valid float
if value is None or value == '':
self.__dict__[name] = float('nan')
else:
if not focused:
self.__dict__[name] = float(value)
else:
# Autocomplete focused values tend to be garbage in
self.__dict__[name] = value
elif opt_type in (6, 7, 8, 9, 11):
# Remaining ones should be snowflake based ones with resolved data
snowflake: str = option['value'] # type: ignore # Key is there
if opt_type == 9: # Mentionable
# Mentionable is User | Role, these do not cause any conflict
key = ResolveKey.any_with(snowflake)
else:
# The remaining keys can conflict, for example, a role and a channel
# could end up with the same ID in very old guilds since they used to default
# to sharing the guild ID. Old general channels no longer exist, but some old
# servers will still have them so this needs to be handled.
key = ResolveKey(id=snowflake, type=opt_type)
value = completed.get(key) or Object(id=int(snowflake))
self.__dict__[name] = value
@classmethod
def _get_resolved_items(cls, interaction: Interaction, resolved: ResolvedData) -> Dict[ResolveKey, Any]:
completed: Dict[ResolveKey, Any] = {}
state = interaction._state
members = resolved.get('members', {})
guild_id = interaction.guild_id
guild = interaction.guild
type = AppCommandOptionType.user.value
for user_id, user_data in resolved.get('users', {}).items():
try:
member_data = members[user_id]
except KeyError:
completed[ResolveKey(id=user_id, type=type)] = state.create_user(user_data)
else:
member_data['user'] = user_data
# Guild ID can't be None in this case.
# There's a type mismatch here that I don't actually care about
member = Member(state=state, guild=guild, data=member_data) # type: ignore
completed[ResolveKey(id=user_id, type=type)] = member
type = AppCommandOptionType.role.value
completed.update(
{
# The guild ID can't be None in this case.
ResolveKey(id=role_id, type=type): Role(guild=guild, state=state, data=role_data) # type: ignore
for role_id, role_data in resolved.get('roles', {}).items()
}
)
type = AppCommandOptionType.channel.value
for channel_id, channel_data in resolved.get('channels', {}).items():
key = ResolveKey(id=channel_id, type=type)
if channel_data['type'] in (10, 11, 12):
# The guild ID can't be none in this case
completed[key] = AppCommandThread(state=state, data=channel_data, guild_id=guild_id) # type: ignore
else:
# The guild ID can't be none in this case
completed[key] = AppCommandChannel(state=state, data=channel_data, guild_id=guild_id) # type: ignore
type = AppCommandOptionType.attachment.value
completed.update(
{
ResolveKey(id=attachment_id, type=type): Attachment(data=attachment_data, state=state)
for attachment_id, attachment_data in resolved.get('attachments', {}).items()
}
)
for message_id, message_data in resolved.get('messages', {}).items():
channel_id = int(message_data['channel_id'])
if guild is None:
channel = PartialMessageable(state=state, guild_id=guild_id, id=channel_id)
else:
channel = guild.get_channel_or_thread(channel_id) or PartialMessageable(
state=state, guild_id=guild_id, id=channel_id
)
# Type checker doesn't understand this due to failure to narrow
message = Message(state=state, channel=channel, data=message_data) # type: ignore
message.guild = guild
key = ResolveKey(id=message_id, type=-1)
completed[key] = message
return completed
def __repr__(self) -> str:
items = (f'{k}={v!r}' for k, v in self.__dict__.items())
return '<{} {}>'.format(self.__class__.__name__, ' '.join(items))
def __eq__(self, other: object) -> bool:
if isinstance(self, Namespace) and isinstance(other, Namespace):
return self.__dict__ == other.__dict__
return NotImplemented
def __getitem__(self, key: str) -> Any:
return self.__dict__[key]
def __contains__(self, key: str) -> Any:
return key in self.__dict__
def __getattr__(self, attr: str) -> Any:
return None
def __iter__(self) -> Iterator[Tuple[str, Any]]:
yield from self.__dict__.items()
def _update_with_defaults(self, defaults: Iterable[Tuple[str, Any]]) -> None:
for key, value in defaults:
self.__dict__.setdefault(key, value)

View File

@@ -0,0 +1,880 @@
"""
The MIT License (MIT)
Copyright (c) 2015-present Rapptz
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
import inspect
from dataclasses import dataclass
from enum import Enum
from typing import (
TYPE_CHECKING,
Any,
Callable,
ClassVar,
Coroutine,
Dict,
Generic,
List,
Literal,
Optional,
Set,
Tuple,
Type,
TypeVar,
Union,
)
from .errors import AppCommandError, TransformerError
from .models import AppCommandChannel, AppCommandThread, Choice
from .translator import TranslationContextLocation, TranslationContext, Translator, locale_str
from ..channel import StageChannel, VoiceChannel, TextChannel, CategoryChannel, ForumChannel
from ..abc import GuildChannel
from ..threads import Thread
from ..enums import Enum as InternalEnum, AppCommandOptionType, ChannelType, Locale
from ..utils import MISSING, maybe_coroutine, _human_join
from ..user import User
from ..role import Role
from ..member import Member
from ..message import Attachment
from .._types import ClientT
__all__ = (
'Transformer',
'Transform',
'Range',
)
T = TypeVar('T')
FuncT = TypeVar('FuncT', bound=Callable[..., Any])
ChoiceT = TypeVar('ChoiceT', str, int, float, Union[str, int, float])
NoneType = type(None)
if TYPE_CHECKING:
from ..interactions import Interaction
from .commands import Parameter
@dataclass
class CommandParameter:
# The name of the parameter is *always* the parameter name in the code
# Therefore, it can't be Union[str, locale_str]
name: str = MISSING
description: Union[str, locale_str] = MISSING
required: bool = MISSING
default: Any = MISSING
choices: List[Choice[Union[str, int, float]]] = MISSING
type: AppCommandOptionType = MISSING
channel_types: List[ChannelType] = MISSING
min_value: Optional[Union[int, float]] = None
max_value: Optional[Union[int, float]] = None
autocomplete: Optional[Callable[..., Coroutine[Any, Any, Any]]] = None
_rename: Union[str, locale_str] = MISSING
_annotation: Any = MISSING
async def get_translated_payload(self, translator: Translator, data: Parameter) -> Dict[str, Any]:
base = self.to_dict()
rename = self._rename
description = self.description
needs_name_translations = isinstance(rename, locale_str)
needs_description_translations = isinstance(description, locale_str)
name_localizations: Dict[str, str] = {}
description_localizations: Dict[str, str] = {}
# Prevent creating these objects in a heavy loop
name_context = TranslationContext(location=TranslationContextLocation.parameter_name, data=data)
description_context = TranslationContext(location=TranslationContextLocation.parameter_description, data=data)
for locale in Locale:
if needs_name_translations:
translation = await translator._checked_translate(rename, locale, name_context)
if translation is not None:
name_localizations[locale.value] = translation
if needs_description_translations:
translation = await translator._checked_translate(description, locale, description_context)
if translation is not None:
description_localizations[locale.value] = translation
if self.choices:
base['choices'] = [await choice.get_translated_payload(translator) for choice in self.choices]
if name_localizations:
base['name_localizations'] = name_localizations
if description_localizations:
base['description_localizations'] = description_localizations
return base
def to_dict(self) -> Dict[str, Any]:
base = {
'type': self.type.value,
'name': self.display_name,
'description': str(self.description),
'required': self.required,
}
if self.choices:
base['choices'] = [choice.to_dict() for choice in self.choices]
if self.channel_types:
base['channel_types'] = [t.value for t in self.channel_types]
if self.autocomplete:
base['autocomplete'] = True
min_key, max_key = (
('min_value', 'max_value') if self.type is not AppCommandOptionType.string else ('min_length', 'max_length')
)
if self.min_value is not None:
base[min_key] = self.min_value
if self.max_value is not None:
base[max_key] = self.max_value
return base
def _convert_to_locale_strings(self) -> None:
if self._rename is MISSING:
self._rename = locale_str(self.name)
elif isinstance(self._rename, str):
self._rename = locale_str(self._rename)
if isinstance(self.description, str):
self.description = locale_str(self.description)
if self.choices:
for choice in self.choices:
if choice._locale_name is None:
choice._locale_name = locale_str(choice.name)
def is_choice_annotation(self) -> bool:
return getattr(self._annotation, '__discord_app_commands_is_choice__', False)
async def transform(self, interaction: Interaction, value: Any, /) -> Any:
if hasattr(self._annotation, '__discord_app_commands_transformer__'):
# This one needs special handling for type safety reasons
if self._annotation.__discord_app_commands_is_choice__:
choice = next((c for c in self.choices if c.value == value), None)
if choice is None:
raise TransformerError(value, self.type, self._annotation)
return choice
try:
return await maybe_coroutine(self._annotation.transform, interaction, value)
except AppCommandError:
raise
except Exception as e:
raise TransformerError(value, self.type, self._annotation) from e
return value
@property
def display_name(self) -> str:
""":class:`str`: The name of the parameter as it should be displayed to the user."""
return self.name if self._rename is MISSING else str(self._rename)
class Transformer(Generic[ClientT]):
"""The base class that allows a type annotation in an application command parameter
to map into a :class:`~discord.AppCommandOptionType` and transform the raw value into one
from this type.
This class is customisable through the overriding of methods and properties in the class
and by using it as the second type parameter of the :class:`~discord.app_commands.Transform`
class. For example, to convert a string into a custom pair type:
.. code-block:: python3
class Point(typing.NamedTuple):
x: int
y: int
class PointTransformer(app_commands.Transformer):
async def transform(self, interaction: discord.Interaction, value: str) -> Point:
(x, _, y) = value.partition(',')
return Point(x=int(x.strip()), y=int(y.strip()))
@app_commands.command()
async def graph(
interaction: discord.Interaction,
point: app_commands.Transform[Point, PointTransformer],
):
await interaction.response.send_message(str(point))
If a class is passed instead of an instance to the second type parameter, then it is
constructed with no arguments passed to the ``__init__`` method.
.. versionadded:: 2.0
"""
__discord_app_commands_transformer__: ClassVar[bool] = True
__discord_app_commands_is_choice__: ClassVar[bool] = False
# This is needed to pass typing's type checks.
# e.g. Optional[MyTransformer]
def __call__(self) -> None:
pass
def __or__(self, rhs: Any) -> Any:
return Union[self, rhs]
@property
def type(self) -> AppCommandOptionType:
""":class:`~discord.AppCommandOptionType`: The option type associated with this transformer.
This must be a :obj:`property`.
Defaults to :attr:`~discord.AppCommandOptionType.string`.
"""
return AppCommandOptionType.string
@property
def channel_types(self) -> List[ChannelType]:
"""List[:class:`~discord.ChannelType`]: A list of channel types that are allowed to this parameter.
Only valid if the :meth:`type` returns :attr:`~discord.AppCommandOptionType.channel`.
This must be a :obj:`property`.
Defaults to an empty list.
"""
return []
@property
def min_value(self) -> Optional[Union[int, float]]:
"""Optional[:class:`int`]: The minimum supported value for this parameter.
Only valid if the :meth:`type` returns :attr:`~discord.AppCommandOptionType.number`
:attr:`~discord.AppCommandOptionType.integer`, or :attr:`~discord.AppCommandOptionType.string`.
This must be a :obj:`property`.
Defaults to ``None``.
"""
return None
@property
def max_value(self) -> Optional[Union[int, float]]:
"""Optional[:class:`int`]: The maximum supported value for this parameter.
Only valid if the :meth:`type` returns :attr:`~discord.AppCommandOptionType.number`
:attr:`~discord.AppCommandOptionType.integer`, or :attr:`~discord.AppCommandOptionType.string`.
This must be a :obj:`property`.
Defaults to ``None``.
"""
return None
@property
def choices(self) -> Optional[List[Choice[Union[int, float, str]]]]:
"""Optional[List[:class:`~discord.app_commands.Choice`]]: A list of up to 25 choices that are allowed to this parameter.
Only valid if the :meth:`type` returns :attr:`~discord.AppCommandOptionType.number`
:attr:`~discord.AppCommandOptionType.integer`, or :attr:`~discord.AppCommandOptionType.string`.
This must be a :obj:`property`.
Defaults to ``None``.
"""
return None
@property
def _error_display_name(self) -> str:
name = self.__class__.__name__
if name.endswith('Transformer'):
return name[:-11]
else:
return name
async def transform(self, interaction: Interaction[ClientT], value: Any, /) -> Any:
"""|maybecoro|
Transforms the converted option value into another value.
The value passed into this transform function is the same as the
one in the :class:`conversion table <discord.app_commands.Namespace>`.
Parameters
-----------
interaction: :class:`~discord.Interaction`
The interaction being handled.
value: Any
The value of the given argument after being resolved.
See the :class:`conversion table <discord.app_commands.Namespace>`
for how certain option types correspond to certain values.
"""
raise NotImplementedError('Derived classes need to implement this.')
async def autocomplete(
self, interaction: Interaction[ClientT], value: Union[int, float, str], /
) -> List[Choice[Union[int, float, str]]]:
"""|coro|
An autocomplete prompt handler to be automatically used by options using this transformer.
.. note::
Autocomplete is only supported for options with a :meth:`~discord.app_commands.Transformer.type`
of :attr:`~discord.AppCommandOptionType.string`, :attr:`~discord.AppCommandOptionType.integer`,
or :attr:`~discord.AppCommandOptionType.number`.
Parameters
-----------
interaction: :class:`~discord.Interaction`
The autocomplete interaction being handled.
value: Union[:class:`str`, :class:`int`, :class:`float`]
The current value entered by the user.
Returns
--------
List[:class:`~discord.app_commands.Choice`]
A list of choices to be displayed to the user, a maximum of 25.
"""
raise NotImplementedError('Derived classes can implement this.')
class IdentityTransformer(Transformer[ClientT]):
def __init__(self, type: AppCommandOptionType) -> None:
self._type = type
@property
def type(self) -> AppCommandOptionType:
return self._type
async def transform(self, interaction: Interaction[ClientT], value: Any, /) -> Any:
return value
class RangeTransformer(IdentityTransformer):
def __init__(
self,
opt_type: AppCommandOptionType,
*,
min: Optional[Union[int, float]] = None,
max: Optional[Union[int, float]] = None,
) -> None:
if min and max and min > max:
raise TypeError('minimum cannot be larger than maximum')
self._min: Optional[Union[int, float]] = min
self._max: Optional[Union[int, float]] = max
super().__init__(opt_type)
@property
def min_value(self) -> Optional[Union[int, float]]:
return self._min
@property
def max_value(self) -> Optional[Union[int, float]]:
return self._max
class LiteralTransformer(IdentityTransformer):
def __init__(self, values: Tuple[Any, ...]) -> None:
first = type(values[0])
if first is int:
opt_type = AppCommandOptionType.integer
elif first is float:
opt_type = AppCommandOptionType.number
elif first is str:
opt_type = AppCommandOptionType.string
else:
raise TypeError(f'expected int, str, or float values not {first!r}')
self._choices = [Choice(name=str(v), value=v) for v in values]
super().__init__(opt_type)
@property
def choices(self):
return self._choices
class ChoiceTransformer(IdentityTransformer):
__discord_app_commands_is_choice__: ClassVar[bool] = True
def __init__(self, inner_type: Any) -> None:
if inner_type is int:
opt_type = AppCommandOptionType.integer
elif inner_type is float:
opt_type = AppCommandOptionType.number
elif inner_type is str:
opt_type = AppCommandOptionType.string
else:
raise TypeError(f'expected int, str, or float values not {inner_type!r}')
super().__init__(opt_type)
class EnumValueTransformer(Transformer):
def __init__(self, enum: Any) -> None:
super().__init__()
values = list(enum)
if len(values) < 2:
raise TypeError('enum.Enum requires at least two values.')
first = type(values[0].value)
if first is int:
opt_type = AppCommandOptionType.integer
elif first is float:
opt_type = AppCommandOptionType.number
elif first is str:
opt_type = AppCommandOptionType.string
else:
raise TypeError(f'expected int, str, or float values not {first!r}')
self._type: AppCommandOptionType = opt_type
self._enum: Any = enum
self._choices = [Choice(name=v.name, value=v.value) for v in values]
@property
def _error_display_name(self) -> str:
return self._enum.__name__
@property
def type(self) -> AppCommandOptionType:
return self._type
@property
def choices(self):
return self._choices
async def transform(self, interaction: Interaction, value: Any, /) -> Any:
return self._enum(value)
class EnumNameTransformer(Transformer):
def __init__(self, enum: Any) -> None:
super().__init__()
values = list(enum)
if len(values) < 2:
raise TypeError('enum.Enum requires at least two values.')
self._enum: Any = enum
self._choices = [Choice(name=v.name, value=v.name) for v in values]
@property
def _error_display_name(self) -> str:
return self._enum.__name__
@property
def type(self) -> AppCommandOptionType:
return AppCommandOptionType.string
@property
def choices(self):
return self._choices
async def transform(self, interaction: Interaction, value: Any, /) -> Any:
return self._enum[value]
class InlineTransformer(Transformer[ClientT]):
def __init__(self, annotation: Any) -> None:
super().__init__()
self.annotation: Any = annotation
@property
def _error_display_name(self) -> str:
return self.annotation.__name__
@property
def type(self) -> AppCommandOptionType:
return AppCommandOptionType.string
async def transform(self, interaction: Interaction[ClientT], value: Any, /) -> Any:
return await self.annotation.transform(interaction, value)
if TYPE_CHECKING:
from typing_extensions import Annotated as Transform
from typing_extensions import Annotated as Range
else:
class Transform:
"""A type annotation that can be applied to a parameter to customise the behaviour of
an option type by transforming with the given :class:`Transformer`. This requires
the usage of two generic parameters, the first one is the type you're converting to and the second
one is the type of the :class:`Transformer` actually doing the transformation.
During type checking time this is equivalent to :obj:`typing.Annotated` so type checkers understand
the intent of the code.
For example usage, check :class:`Transformer`.
.. versionadded:: 2.0
"""
def __class_getitem__(cls, items) -> Transformer:
if not isinstance(items, tuple):
raise TypeError(f'expected tuple for arguments, received {items.__class__.__name__} instead')
if len(items) != 2:
raise TypeError('Transform only accepts exactly two arguments')
_, transformer = items
if inspect.isclass(transformer):
if not issubclass(transformer, Transformer):
raise TypeError(f'second argument of Transform must be a Transformer class not {transformer!r}')
transformer = transformer()
elif not isinstance(transformer, Transformer):
raise TypeError(f'second argument of Transform must be a Transformer not {transformer.__class__.__name__}')
return transformer
class Range:
"""A type annotation that can be applied to a parameter to require a numeric or string
type to fit within the range provided.
During type checking time this is equivalent to :obj:`typing.Annotated` so type checkers understand
the intent of the code.
Some example ranges:
- ``Range[int, 10]`` means the minimum is 10 with no maximum.
- ``Range[int, None, 10]`` means the maximum is 10 with no minimum.
- ``Range[int, 1, 10]`` means the minimum is 1 and the maximum is 10.
- ``Range[float, 1.0, 5.0]`` means the minimum is 1.0 and the maximum is 5.0.
- ``Range[str, 1, 10]`` means the minimum length is 1 and the maximum length is 10.
.. versionadded:: 2.0
Examples
----------
.. code-block:: python3
@app_commands.command()
async def range(interaction: discord.Interaction, value: app_commands.Range[int, 10, 12]):
await interaction.response.send_message(f'Your value is {value}', ephemeral=True)
"""
def __class_getitem__(cls, obj) -> RangeTransformer:
if not isinstance(obj, tuple):
raise TypeError(f'expected tuple for arguments, received {obj.__class__.__name__} instead')
if len(obj) == 2:
obj = (*obj, None)
elif len(obj) != 3:
raise TypeError('Range accepts either two or three arguments with the first being the type of range.')
obj_type, min, max = obj
if min is None and max is None:
raise TypeError('Range must not be empty')
if min is not None and max is not None:
# At this point max and min are both not none
if type(min) != type(max):
raise TypeError('Both min and max in Range must be the same type')
if obj_type is int:
opt_type = AppCommandOptionType.integer
elif obj_type is float:
opt_type = AppCommandOptionType.number
elif obj_type is str:
opt_type = AppCommandOptionType.string
else:
raise TypeError(f'expected int, float, or str as range type, received {obj_type!r} instead')
if obj_type in (str, int):
cast = int
else:
cast = float
transformer = RangeTransformer(
opt_type,
min=cast(min) if min is not None else None,
max=cast(max) if max is not None else None,
)
return transformer
class MemberTransformer(Transformer[ClientT]):
@property
def type(self) -> AppCommandOptionType:
return AppCommandOptionType.user
async def transform(self, interaction: Interaction[ClientT], value: Any, /) -> Member:
if not isinstance(value, Member):
raise TransformerError(value, self.type, self)
return value
class BaseChannelTransformer(Transformer[ClientT]):
def __init__(self, *channel_types: Type[Any]) -> None:
super().__init__()
if len(channel_types) == 1:
display_name = channel_types[0].__name__
types = CHANNEL_TO_TYPES[channel_types[0]]
else:
display_name = _human_join([t.__name__ for t in channel_types])
types = []
for t in channel_types:
try:
types.extend(CHANNEL_TO_TYPES[t])
except KeyError:
raise TypeError('Union type of channels must be entirely made up of channels') from None
self._types: Tuple[Type[Any], ...] = channel_types
self._channel_types: List[ChannelType] = types
self._display_name = display_name
@property
def _error_display_name(self) -> str:
return self._display_name
@property
def type(self) -> AppCommandOptionType:
return AppCommandOptionType.channel
@property
def channel_types(self) -> List[ChannelType]:
return self._channel_types
async def transform(self, interaction: Interaction[ClientT], value: Any, /):
resolved = value.resolve()
if resolved is None or not isinstance(resolved, self._types):
raise TransformerError(value, AppCommandOptionType.channel, self)
return resolved
class RawChannelTransformer(BaseChannelTransformer[ClientT]):
async def transform(self, interaction: Interaction[ClientT], value: Any, /):
if not isinstance(value, self._types):
raise TransformerError(value, AppCommandOptionType.channel, self)
return value
class UnionChannelTransformer(BaseChannelTransformer[ClientT]):
async def transform(self, interaction: Interaction[ClientT], value: Any, /):
if isinstance(value, self._types):
return value
resolved = value.resolve()
if resolved is None or not isinstance(resolved, self._types):
raise TransformerError(value, AppCommandOptionType.channel, self)
return resolved
CHANNEL_TO_TYPES: Dict[Any, List[ChannelType]] = {
AppCommandChannel: [
ChannelType.stage_voice,
ChannelType.voice,
ChannelType.text,
ChannelType.news,
ChannelType.category,
ChannelType.forum,
ChannelType.media,
],
GuildChannel: [
ChannelType.stage_voice,
ChannelType.voice,
ChannelType.text,
ChannelType.news,
ChannelType.category,
ChannelType.forum,
ChannelType.media,
],
AppCommandThread: [ChannelType.news_thread, ChannelType.private_thread, ChannelType.public_thread],
Thread: [ChannelType.news_thread, ChannelType.private_thread, ChannelType.public_thread],
StageChannel: [ChannelType.stage_voice],
VoiceChannel: [ChannelType.voice],
TextChannel: [ChannelType.text, ChannelType.news],
CategoryChannel: [ChannelType.category],
ForumChannel: [ChannelType.forum, ChannelType.media],
}
BUILT_IN_TRANSFORMERS: Dict[Any, Transformer] = {
str: IdentityTransformer(AppCommandOptionType.string),
int: IdentityTransformer(AppCommandOptionType.integer),
float: IdentityTransformer(AppCommandOptionType.number),
bool: IdentityTransformer(AppCommandOptionType.boolean),
User: IdentityTransformer(AppCommandOptionType.user),
Member: MemberTransformer(),
Role: IdentityTransformer(AppCommandOptionType.role),
AppCommandChannel: RawChannelTransformer(AppCommandChannel),
AppCommandThread: RawChannelTransformer(AppCommandThread),
GuildChannel: BaseChannelTransformer(GuildChannel),
Thread: BaseChannelTransformer(Thread),
StageChannel: BaseChannelTransformer(StageChannel),
VoiceChannel: BaseChannelTransformer(VoiceChannel),
TextChannel: BaseChannelTransformer(TextChannel),
CategoryChannel: BaseChannelTransformer(CategoryChannel),
ForumChannel: BaseChannelTransformer(ForumChannel),
Attachment: IdentityTransformer(AppCommandOptionType.attachment),
}
ALLOWED_DEFAULTS: Dict[AppCommandOptionType, Tuple[Type[Any], ...]] = {
AppCommandOptionType.string: (str, NoneType),
AppCommandOptionType.integer: (int, NoneType),
AppCommandOptionType.boolean: (bool, NoneType),
AppCommandOptionType.number: (float, NoneType),
}
def get_supported_annotation(
annotation: Any,
*,
_none: type = NoneType,
_mapping: Dict[Any, Transformer] = BUILT_IN_TRANSFORMERS,
) -> Tuple[Any, Any, bool]:
"""Returns an appropriate, yet supported, annotation along with an optional default value.
The third boolean element of the tuple indicates if default values should be validated.
This differs from the built in mapping by supporting a few more things.
Likewise, this returns a "transformed" annotation that is ready to use with CommandParameter.transform.
"""
try:
return (_mapping[annotation], MISSING, True)
except (KeyError, TypeError):
pass
if isinstance(annotation, Transformer):
return (annotation, MISSING, False)
if inspect.isclass(annotation):
if issubclass(annotation, Transformer):
return (annotation(), MISSING, False)
if issubclass(annotation, (Enum, InternalEnum)):
if all(isinstance(v.value, (str, int, float)) for v in annotation):
return (EnumValueTransformer(annotation), MISSING, False)
else:
return (EnumNameTransformer(annotation), MISSING, False)
if annotation is Choice:
raise TypeError('Choice requires a type argument of int, str, or float')
# Check if a transform @classmethod is given to the class
# These flatten into simple "inline" transformers with implicit strings
transform_classmethod = annotation.__dict__.get('transform', None)
if isinstance(transform_classmethod, classmethod):
params = inspect.signature(transform_classmethod.__func__).parameters
if len(params) != 3:
raise TypeError('Inline transformer with transform classmethod requires 3 parameters')
if not inspect.iscoroutinefunction(transform_classmethod.__func__):
raise TypeError('Inline transformer with transform classmethod must be a coroutine')
return (InlineTransformer(annotation), MISSING, False)
# Check if there's an origin
origin = getattr(annotation, '__origin__', None)
if origin is Literal:
args = annotation.__args__
return (LiteralTransformer(args), MISSING, True)
if origin is Choice:
arg = annotation.__args__[0]
return (ChoiceTransformer(arg), MISSING, True)
if origin is not Union:
# Only Union/Optional is supported right now so bail early
raise TypeError(f'unsupported type annotation {annotation!r}')
default = MISSING
args = annotation.__args__
if args[-1] is _none:
if len(args) == 2:
underlying = args[0]
inner, _, validate_default = get_supported_annotation(underlying)
if inner is None:
raise TypeError(f'unsupported inner optional type {underlying!r}')
return (inner, None, validate_default)
else:
args = args[:-1]
default = None
# Check for channel union types
if any(arg in CHANNEL_TO_TYPES for arg in args):
# If any channel type is given, then *all* must be channel types
return (UnionChannelTransformer(*args), default, True)
# The only valid transformations here are:
# [Member, User] => user
# [Member, User, Role] => mentionable
# [Member | User, Role] => mentionable
supported_types: Set[Any] = {Role, Member, User}
if not all(arg in supported_types for arg in args):
raise TypeError(f'unsupported types given inside {annotation!r}')
if args == (User, Member) or args == (Member, User):
return (IdentityTransformer(AppCommandOptionType.user), default, True)
return (IdentityTransformer(AppCommandOptionType.mentionable), default, True)
def annotation_to_parameter(annotation: Any, parameter: inspect.Parameter) -> CommandParameter:
"""Returns the appropriate :class:`CommandParameter` for the given annotation.
The resulting ``_annotation`` attribute might not match the one given here and might
be transformed in order to be easier to call from the ``transform`` asynchronous function
of a command parameter.
"""
(inner, default, validate_default) = get_supported_annotation(annotation)
type = inner.type
if default is MISSING or default is None:
param_default = parameter.default
if param_default is not parameter.empty:
default = param_default
# Verify validity of the default parameter
if default is not MISSING and validate_default:
valid_types: Tuple[Any, ...] = ALLOWED_DEFAULTS.get(type, (NoneType,))
if not isinstance(default, valid_types):
raise TypeError(f'invalid default parameter type given ({default.__class__}), expected {valid_types}')
result = CommandParameter(
type=type,
_annotation=inner,
default=default,
required=default is MISSING,
name=parameter.name,
)
choices = inner.choices
if choices is not None:
result.choices = choices
# These methods should be duck typed
if type in (AppCommandOptionType.number, AppCommandOptionType.string, AppCommandOptionType.integer):
result.min_value = inner.min_value
result.max_value = inner.max_value
if type is AppCommandOptionType.channel:
result.channel_types = inner.channel_types
if parameter.kind in (parameter.POSITIONAL_ONLY, parameter.VAR_KEYWORD, parameter.VAR_POSITIONAL):
raise TypeError(f'unsupported parameter kind in callback: {parameter.kind!s}')
# Check if the method is overridden
if inner.autocomplete.__func__ is not Transformer.autocomplete:
from .commands import validate_auto_complete_callback
result.autocomplete = validate_auto_complete_callback(inner.autocomplete)
return result

View File

@@ -0,0 +1,299 @@
"""
The MIT License (MIT)
Copyright (c) 2015-present Rapptz
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Generic, Literal, Optional, TypeVar, Union, overload
from .errors import TranslationError
from ..enums import Enum, Locale
if TYPE_CHECKING:
from .commands import Command, ContextMenu, Group, Parameter
from .models import Choice
__all__ = (
'TranslationContextLocation',
'TranslationContextTypes',
'TranslationContext',
'Translator',
'locale_str',
)
class TranslationContextLocation(Enum):
command_name = 0
command_description = 1
group_name = 2
group_description = 3
parameter_name = 4
parameter_description = 5
choice_name = 6
other = 7
_L = TypeVar('_L', bound=TranslationContextLocation)
_D = TypeVar('_D')
class TranslationContext(Generic[_L, _D]):
"""A class that provides context for the :class:`locale_str` being translated.
This is useful to determine where exactly the string is located and aid in looking
up the actual translation.
Attributes
-----------
location: :class:`TranslationContextLocation`
The location where this string is located.
data: Any
The extraneous data that is being translated.
"""
__slots__ = ('location', 'data')
@overload
def __init__(
self, location: Literal[TranslationContextLocation.command_name], data: Union[Command[Any, ..., Any], ContextMenu]
) -> None: ...
@overload
def __init__(
self, location: Literal[TranslationContextLocation.command_description], data: Command[Any, ..., Any]
) -> None: ...
@overload
def __init__(
self,
location: Literal[TranslationContextLocation.group_name, TranslationContextLocation.group_description],
data: Group,
) -> None: ...
@overload
def __init__(
self,
location: Literal[TranslationContextLocation.parameter_name, TranslationContextLocation.parameter_description],
data: Parameter,
) -> None: ...
@overload
def __init__(self, location: Literal[TranslationContextLocation.choice_name], data: Choice[Any]) -> None: ...
@overload
def __init__(self, location: Literal[TranslationContextLocation.other], data: Any) -> None: ...
def __init__(self, location: _L, data: _D) -> None: # type: ignore # pyright doesn't like the overloads
self.location: _L = location
self.data: _D = data
# For type checking purposes, it makes sense to allow the user to leverage type narrowing
# So code like this works as expected:
#
# if context.type == TranslationContextLocation.command_name:
# reveal_type(context.data) # Revealed type is Command | ContextMenu
#
# This requires a union of types
CommandNameTranslationContext = TranslationContext[
Literal[TranslationContextLocation.command_name], Union['Command[Any, ..., Any]', 'ContextMenu']
]
CommandDescriptionTranslationContext = TranslationContext[
Literal[TranslationContextLocation.command_description], 'Command[Any, ..., Any]'
]
GroupTranslationContext = TranslationContext[
Literal[TranslationContextLocation.group_name, TranslationContextLocation.group_description], 'Group'
]
ParameterTranslationContext = TranslationContext[
Literal[TranslationContextLocation.parameter_name, TranslationContextLocation.parameter_description], 'Parameter'
]
ChoiceTranslationContext = TranslationContext[Literal[TranslationContextLocation.choice_name], 'Choice[Any]']
OtherTranslationContext = TranslationContext[Literal[TranslationContextLocation.other], Any]
TranslationContextTypes = Union[
CommandNameTranslationContext,
CommandDescriptionTranslationContext,
GroupTranslationContext,
ParameterTranslationContext,
ChoiceTranslationContext,
OtherTranslationContext,
]
class Translator:
"""A class that handles translations for commands, parameters, and choices.
Translations are done lazily in order to allow for async enabled translations as well
as supporting a wide array of translation systems such as :mod:`gettext` and
`Project Fluent <https://projectfluent.org>`_.
In order for a translator to be used, it must be set using the :meth:`CommandTree.set_translator`
method. The translation flow for a string is as follows:
1. Use :class:`locale_str` instead of :class:`str` in areas of a command you want to be translated.
- Currently, these are command names, command descriptions, parameter names, parameter descriptions, and choice names.
- This can also be used inside the :func:`~discord.app_commands.describe` decorator.
2. Call :meth:`CommandTree.set_translator` to the translator instance that will handle the translations.
3. Call :meth:`CommandTree.sync`
4. The library will call :meth:`Translator.translate` on all the relevant strings being translated.
.. versionadded:: 2.0
"""
async def load(self) -> None:
"""|coro|
An asynchronous setup function for loading the translation system.
The default implementation does nothing.
This is invoked when :meth:`CommandTree.set_translator` is called.
"""
pass
async def unload(self) -> None:
"""|coro|
An asynchronous teardown function for unloading the translation system.
The default implementation does nothing.
This is invoked when :meth:`CommandTree.set_translator` is called
if a tree already has a translator or when :meth:`discord.Client.close` is called.
"""
pass
async def _checked_translate(
self, string: locale_str, locale: Locale, context: TranslationContextTypes
) -> Optional[str]:
try:
return await self.translate(string, locale, context)
except TranslationError:
raise
except Exception as e:
raise TranslationError(string=string, locale=locale, context=context) from e
async def translate(self, string: locale_str, locale: Locale, context: TranslationContextTypes) -> Optional[str]:
"""|coro|
Translates the given string to the specified locale.
If the string cannot be translated, ``None`` should be returned.
The default implementation returns ``None``.
If an exception is raised in this method, it should inherit from :exc:`TranslationError`.
If it doesn't, then when this is called the exception will be chained with it instead.
Parameters
------------
string: :class:`locale_str`
The string being translated.
locale: :class:`~discord.Locale`
The locale being requested for translation.
context: :class:`TranslationContext`
The translation context where the string originated from.
For better type checking ergonomics, the ``TranslationContextTypes``
type can be used instead to aid with type narrowing. It is functionally
equivalent to :class:`TranslationContext`.
"""
return None
class locale_str:
"""Marks a string as ready for translation.
This is done lazily and is not actually translated until :meth:`CommandTree.sync` is called.
The sync method then ultimately defers the responsibility of translating to the :class:`Translator`
instance used by the :class:`CommandTree`. For more information on the translation flow, see the
:class:`Translator` documentation.
.. container:: operations
.. describe:: str(x)
Returns the message passed to the string.
.. describe:: x == y
Checks if the string is equal to another string.
.. describe:: x != y
Checks if the string is not equal to another string.
.. describe:: hash(x)
Returns the hash of the string.
.. versionadded:: 2.0
Attributes
------------
message: :class:`str`
The message being translated. Once set, this cannot be changed.
.. warning::
This must be the default "message" that you send to Discord.
Discord sends this message back to the library and the library
uses it to access the data in order to dispatch commands.
For example, in a command name context, if the command
name is ``foo`` then the message *must* also be ``foo``.
For other translation systems that require a message ID such
as Fluent, consider using a keyword argument to pass it in.
extras: :class:`dict`
A dict of user provided extras to attach to the translated string.
This can be used to add more context, information, or any metadata necessary
to aid in actually translating the string.
Since these are passed via keyword arguments, the keys are strings.
"""
__slots__ = ('__message', 'extras')
def __init__(self, message: str, /, **kwargs: Any) -> None:
self.__message: str = message
self.extras: dict[str, Any] = kwargs
@property
def message(self) -> str:
return self.__message
def __str__(self) -> str:
return self.__message
def __repr__(self) -> str:
kwargs = ', '.join(f'{k}={v!r}' for k, v in self.extras.items())
if kwargs:
return f'{self.__class__.__name__}({self.__message!r}, {kwargs})'
return f'{self.__class__.__name__}({self.__message!r})'
def __eq__(self, obj: object) -> bool:
return isinstance(obj, locale_str) and self.message == obj.message
def __hash__(self) -> int:
return hash(self.__message)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,645 @@
"""
The MIT License (MIT)
Copyright (c) 2015-present Rapptz
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
from typing import List, TYPE_CHECKING, Literal, Optional
from . import utils
from .asset import Asset
from .flags import ApplicationFlags
from .permissions import Permissions
from .utils import MISSING
if TYPE_CHECKING:
from typing import Dict, Any
from .guild import Guild
from .types.appinfo import (
AppInfo as AppInfoPayload,
PartialAppInfo as PartialAppInfoPayload,
Team as TeamPayload,
InstallParams as InstallParamsPayload,
AppIntegrationTypeConfig as AppIntegrationTypeConfigPayload,
)
from .user import User
from .state import ConnectionState
__all__ = (
'AppInfo',
'PartialAppInfo',
'AppInstallParams',
'IntegrationTypeConfig',
)
class AppInfo:
"""Represents the application info for the bot provided by Discord.
Attributes
-------------
id: :class:`int`
The application ID.
name: :class:`str`
The application name.
owner: :class:`User`
The application owner.
team: Optional[:class:`Team`]
The application's team.
.. versionadded:: 1.3
description: :class:`str`
The application description.
bot_public: :class:`bool`
Whether the bot can be invited by anyone or if it is locked
to the application owner.
bot_require_code_grant: :class:`bool`
Whether the bot requires the completion of the full oauth2 code
grant flow to join.
rpc_origins: Optional[List[:class:`str`]]
A list of RPC origin URLs, if RPC is enabled.
verify_key: :class:`str`
The hex encoded key for verification in interactions and the
GameSDK's :ddocs:`GetTicket <game-sdk/applications#getticket>`.
.. versionadded:: 1.3
guild_id: Optional[:class:`int`]
If this application is a game sold on Discord,
this field will be the guild to which it has been linked to.
.. versionadded:: 1.3
primary_sku_id: Optional[:class:`int`]
If this application is a game sold on Discord,
this field will be the id of the "Game SKU" that is created,
if it exists.
.. versionadded:: 1.3
slug: Optional[:class:`str`]
If this application is a game sold on Discord,
this field will be the URL slug that links to the store page.
.. versionadded:: 1.3
terms_of_service_url: Optional[:class:`str`]
The application's terms of service URL, if set.
.. versionadded:: 2.0
privacy_policy_url: Optional[:class:`str`]
The application's privacy policy URL, if set.
.. versionadded:: 2.0
tags: List[:class:`str`]
The list of tags describing the functionality of the application.
.. versionadded:: 2.0
custom_install_url: List[:class:`str`]
The custom authorization URL for the application, if enabled.
.. versionadded:: 2.0
install_params: Optional[:class:`AppInstallParams`]
The settings for custom authorization URL of application, if enabled.
.. versionadded:: 2.0
role_connections_verification_url: Optional[:class:`str`]
The application's connection verification URL which will render the application as
a verification method in the guild's role verification configuration.
.. versionadded:: 2.2
interactions_endpoint_url: Optional[:class:`str`]
The interactions endpoint url of the application to receive interactions over this endpoint rather than
over the gateway, if configured.
.. versionadded:: 2.4
redirect_uris: List[:class:`str`]
A list of authentication redirect URIs.
.. versionadded:: 2.4
approximate_guild_count: :class:`int`
The approximate count of the guilds the bot was added to.
.. versionadded:: 2.4
approximate_user_install_count: Optional[:class:`int`]
The approximate count of the user-level installations the bot has.
.. versionadded:: 2.5
"""
__slots__ = (
'_state',
'description',
'id',
'name',
'rpc_origins',
'bot_public',
'bot_require_code_grant',
'owner',
'_icon',
'verify_key',
'team',
'guild_id',
'primary_sku_id',
'slug',
'_cover_image',
'_flags',
'terms_of_service_url',
'privacy_policy_url',
'tags',
'custom_install_url',
'install_params',
'role_connections_verification_url',
'interactions_endpoint_url',
'redirect_uris',
'approximate_guild_count',
'approximate_user_install_count',
'_integration_types_config',
)
def __init__(self, state: ConnectionState, data: AppInfoPayload):
from .team import Team
self._state: ConnectionState = state
self.id: int = int(data['id'])
self.name: str = data['name']
self.description: str = data['description']
self._icon: Optional[str] = data['icon']
self.rpc_origins: Optional[List[str]] = data.get('rpc_origins')
self.bot_public: bool = data['bot_public']
self.bot_require_code_grant: bool = data['bot_require_code_grant']
self.owner: User = state.create_user(data['owner'])
team: Optional[TeamPayload] = data.get('team')
self.team: Optional[Team] = Team(state, team) if team else None
self.verify_key: str = data['verify_key']
self.guild_id: Optional[int] = utils._get_as_snowflake(data, 'guild_id')
self.primary_sku_id: Optional[int] = utils._get_as_snowflake(data, 'primary_sku_id')
self.slug: Optional[str] = data.get('slug')
self._flags: int = data.get('flags', 0)
self._cover_image: Optional[str] = data.get('cover_image')
self.terms_of_service_url: Optional[str] = data.get('terms_of_service_url')
self.privacy_policy_url: Optional[str] = data.get('privacy_policy_url')
self.tags: List[str] = data.get('tags', [])
self.custom_install_url: Optional[str] = data.get('custom_install_url')
self.role_connections_verification_url: Optional[str] = data.get('role_connections_verification_url')
params = data.get('install_params')
self.install_params: Optional[AppInstallParams] = AppInstallParams(params) if params else None
self.interactions_endpoint_url: Optional[str] = data.get('interactions_endpoint_url')
self.redirect_uris: List[str] = data.get('redirect_uris', [])
self.approximate_guild_count: int = data.get('approximate_guild_count', 0)
self.approximate_user_install_count: Optional[int] = data.get('approximate_user_install_count')
self._integration_types_config: Dict[Literal['0', '1'], AppIntegrationTypeConfigPayload] = data.get(
'integration_types_config', {}
)
def __repr__(self) -> str:
return (
f'<{self.__class__.__name__} id={self.id} name={self.name!r} '
f'description={self.description!r} public={self.bot_public} '
f'owner={self.owner!r}>'
)
@property
def icon(self) -> Optional[Asset]:
"""Optional[:class:`.Asset`]: Retrieves the application's icon asset, if any."""
if self._icon is None:
return None
return Asset._from_icon(self._state, self.id, self._icon, path='app')
@property
def cover_image(self) -> Optional[Asset]:
"""Optional[:class:`.Asset`]: Retrieves the cover image on a store embed, if any.
This is only available if the application is a game sold on Discord.
"""
if self._cover_image is None:
return None
return Asset._from_cover_image(self._state, self.id, self._cover_image)
@property
def guild(self) -> Optional[Guild]:
"""Optional[:class:`Guild`]: If this application is a game sold on Discord,
this field will be the guild to which it has been linked
.. versionadded:: 1.3
"""
return self._state._get_guild(self.guild_id)
@property
def flags(self) -> ApplicationFlags:
""":class:`ApplicationFlags`: The application's flags.
.. versionadded:: 2.0
"""
return ApplicationFlags._from_value(self._flags)
@property
def guild_integration_config(self) -> Optional[IntegrationTypeConfig]:
"""Optional[:class:`IntegrationTypeConfig`]: The default settings for the
application's installation context in a guild.
.. versionadded:: 2.5
"""
if not self._integration_types_config:
return None
try:
return IntegrationTypeConfig(self._integration_types_config['0'])
except KeyError:
return None
@property
def user_integration_config(self) -> Optional[IntegrationTypeConfig]:
"""Optional[:class:`IntegrationTypeConfig`]: The default settings for the
application's installation context as a user.
.. versionadded:: 2.5
"""
if not self._integration_types_config:
return None
try:
return IntegrationTypeConfig(self._integration_types_config['1'])
except KeyError:
return None
async def edit(
self,
*,
reason: Optional[str] = MISSING,
custom_install_url: Optional[str] = MISSING,
description: Optional[str] = MISSING,
role_connections_verification_url: Optional[str] = MISSING,
install_params_scopes: Optional[List[str]] = MISSING,
install_params_permissions: Optional[Permissions] = MISSING,
flags: Optional[ApplicationFlags] = MISSING,
icon: Optional[bytes] = MISSING,
cover_image: Optional[bytes] = MISSING,
interactions_endpoint_url: Optional[str] = MISSING,
tags: Optional[List[str]] = MISSING,
guild_install_scopes: Optional[List[str]] = MISSING,
guild_install_permissions: Optional[Permissions] = MISSING,
user_install_scopes: Optional[List[str]] = MISSING,
user_install_permissions: Optional[Permissions] = MISSING,
) -> AppInfo:
r"""|coro|
Edits the application info.
.. versionadded:: 2.4
Parameters
----------
custom_install_url: Optional[:class:`str`]
The new custom authorization URL for the application. Can be ``None`` to remove the URL.
description: Optional[:class:`str`]
The new application description. Can be ``None`` to remove the description.
role_connections_verification_url: Optional[:class:`str`]
The new applications connection verification URL which will render the application
as a verification method in the guilds role verification configuration. Can be ``None`` to remove the URL.
install_params_scopes: Optional[List[:class:`str`]]
The new list of :ddocs:`OAuth2 scopes <topics/oauth2#shared-resources-oauth2-scopes>` of
the :attr:`~install_params`. Can be ``None`` to remove the scopes.
install_params_permissions: Optional[:class:`Permissions`]
The new permissions of the :attr:`~install_params`. Can be ``None`` to remove the permissions.
flags: Optional[:class:`ApplicationFlags`]
The new applications flags. Only limited intent flags (:attr:`~ApplicationFlags.gateway_presence_limited`,
:attr:`~ApplicationFlags.gateway_guild_members_limited`, :attr:`~ApplicationFlags.gateway_message_content_limited`)
can be edited. Can be ``None`` to remove the flags.
.. warning::
Editing the limited intent flags leads to the termination of the bot.
icon: Optional[:class:`bytes`]
The new applications icon as a :term:`py:bytes-like object`. Can be ``None`` to remove the icon.
cover_image: Optional[:class:`bytes`]
The new applications cover image as a :term:`py:bytes-like object` on a store embed.
The cover image is only available if the application is a game sold on Discord.
Can be ``None`` to remove the image.
interactions_endpoint_url: Optional[:class:`str`]
The new interactions endpoint url of the application to receive interactions over this endpoint rather than
over the gateway. Can be ``None`` to remove the URL.
tags: Optional[List[:class:`str`]]
The new list of tags describing the functionality of the application. Can be ``None`` to remove the tags.
guild_install_scopes: Optional[List[:class:`str`]]
The new list of :ddocs:`OAuth2 scopes <topics/oauth2#shared-resources-oauth2-scopes>` of
the default guild installation context. Can be ``None`` to remove the scopes.
.. versionadded: 2.5
guild_install_permissions: Optional[:class:`Permissions`]
The new permissions of the default guild installation context. Can be ``None`` to remove the permissions.
.. versionadded: 2.5
user_install_scopes: Optional[List[:class:`str`]]
The new list of :ddocs:`OAuth2 scopes <topics/oauth2#shared-resources-oauth2-scopes>` of
the default user installation context. Can be ``None`` to remove the scopes.
.. versionadded: 2.5
user_install_permissions: Optional[:class:`Permissions`]
The new permissions of the default user installation context. Can be ``None`` to remove the permissions.
.. versionadded: 2.5
reason: Optional[:class:`str`]
The reason for editing the application. Shows up on the audit log.
Raises
-------
HTTPException
Editing the application failed
ValueError
The image format passed in to ``icon`` or ``cover_image`` is invalid. This is also raised
when ``install_params_scopes`` and ``install_params_permissions`` are incompatible with each other,
or when ``guild_install_scopes`` and ``guild_install_permissions`` are incompatible with each other.
Returns
-------
:class:`AppInfo`
The newly updated application info.
"""
payload: Dict[str, Any] = {}
if custom_install_url is not MISSING:
payload['custom_install_url'] = custom_install_url
if description is not MISSING:
payload['description'] = description
if role_connections_verification_url is not MISSING:
payload['role_connections_verification_url'] = role_connections_verification_url
if install_params_scopes is not MISSING:
install_params: Optional[Dict[str, Any]] = {}
if install_params_scopes is None:
install_params = None
else:
if 'bot' not in install_params_scopes and install_params_permissions is not MISSING:
raise ValueError("'bot' must be in install_params_scopes if install_params_permissions is set")
install_params['scopes'] = install_params_scopes
if install_params_permissions is MISSING:
install_params['permissions'] = 0
else:
if install_params_permissions is None:
install_params['permissions'] = 0
else:
install_params['permissions'] = install_params_permissions.value
payload['install_params'] = install_params
else:
if install_params_permissions is not MISSING:
raise ValueError('install_params_scopes must be set if install_params_permissions is set')
if flags is not MISSING:
if flags is None:
payload['flags'] = flags
else:
payload['flags'] = flags.value
if icon is not MISSING:
if icon is None:
payload['icon'] = icon
else:
payload['icon'] = utils._bytes_to_base64_data(icon)
if cover_image is not MISSING:
if cover_image is None:
payload['cover_image'] = cover_image
else:
payload['cover_image'] = utils._bytes_to_base64_data(cover_image)
if interactions_endpoint_url is not MISSING:
payload['interactions_endpoint_url'] = interactions_endpoint_url
if tags is not MISSING:
payload['tags'] = tags
integration_types_config: Dict[str, Any] = {}
if guild_install_scopes is not MISSING or guild_install_permissions is not MISSING:
guild_install_params: Optional[Dict[str, Any]] = {}
if guild_install_scopes in (None, MISSING):
guild_install_scopes = []
if 'bot' not in guild_install_scopes and guild_install_permissions is not MISSING:
raise ValueError("'bot' must be in guild_install_scopes if guild_install_permissions is set")
if guild_install_permissions in (None, MISSING):
guild_install_params['permissions'] = 0
else:
guild_install_params['permissions'] = guild_install_permissions.value
guild_install_params['scopes'] = guild_install_scopes
integration_types_config['0'] = {'oauth2_install_params': guild_install_params or None}
else:
if guild_install_permissions is not MISSING:
raise ValueError('guild_install_scopes must be set if guild_install_permissions is set')
if user_install_scopes is not MISSING or user_install_permissions is not MISSING:
user_install_params: Optional[Dict[str, Any]] = {}
if user_install_scopes in (None, MISSING):
user_install_scopes = []
if 'bot' not in user_install_scopes and user_install_permissions is not MISSING:
raise ValueError("'bot' must be in user_install_scopes if user_install_permissions is set")
if user_install_permissions in (None, MISSING):
user_install_params['permissions'] = 0
else:
user_install_params['permissions'] = user_install_permissions.value
user_install_params['scopes'] = user_install_scopes
integration_types_config['1'] = {'oauth2_install_params': user_install_params or None}
else:
if user_install_permissions is not MISSING:
raise ValueError('user_install_scopes must be set if user_install_permissions is set')
if integration_types_config:
payload['integration_types_config'] = integration_types_config
data = await self._state.http.edit_application_info(reason=reason, payload=payload)
return AppInfo(data=data, state=self._state)
class PartialAppInfo:
"""Represents a partial AppInfo given by :func:`~discord.abc.GuildChannel.create_invite`
.. versionadded:: 2.0
Attributes
-------------
id: :class:`int`
The application ID.
name: :class:`str`
The application name.
description: :class:`str`
The application description.
rpc_origins: Optional[List[:class:`str`]]
A list of RPC origin URLs, if RPC is enabled.
verify_key: :class:`str`
The hex encoded key for verification in interactions and the
GameSDK's :ddocs:`GetTicket <game-sdk/applications#getticket>`.
terms_of_service_url: Optional[:class:`str`]
The application's terms of service URL, if set.
privacy_policy_url: Optional[:class:`str`]
The application's privacy policy URL, if set.
approximate_guild_count: :class:`int`
The approximate count of the guilds the bot was added to.
.. versionadded:: 2.3
redirect_uris: List[:class:`str`]
A list of authentication redirect URIs.
.. versionadded:: 2.3
interactions_endpoint_url: Optional[:class:`str`]
The interactions endpoint url of the application to receive interactions over this endpoint rather than
over the gateway, if configured.
.. versionadded:: 2.3
role_connections_verification_url: Optional[:class:`str`]
The application's connection verification URL which will render the application as
a verification method in the guild's role verification configuration.
.. versionadded:: 2.3
"""
__slots__ = (
'_state',
'id',
'name',
'description',
'rpc_origins',
'verify_key',
'terms_of_service_url',
'privacy_policy_url',
'_icon',
'_flags',
'_cover_image',
'approximate_guild_count',
'redirect_uris',
'interactions_endpoint_url',
'role_connections_verification_url',
)
def __init__(self, *, state: ConnectionState, data: PartialAppInfoPayload):
self._state: ConnectionState = state
self.id: int = int(data['id'])
self.name: str = data['name']
self._icon: Optional[str] = data.get('icon')
self._flags: int = data.get('flags', 0)
self._cover_image: Optional[str] = data.get('cover_image')
self.description: str = data['description']
self.rpc_origins: Optional[List[str]] = data.get('rpc_origins')
self.verify_key: str = data['verify_key']
self.terms_of_service_url: Optional[str] = data.get('terms_of_service_url')
self.privacy_policy_url: Optional[str] = data.get('privacy_policy_url')
self.approximate_guild_count: int = data.get('approximate_guild_count', 0)
self.redirect_uris: List[str] = data.get('redirect_uris', [])
self.interactions_endpoint_url: Optional[str] = data.get('interactions_endpoint_url')
self.role_connections_verification_url: Optional[str] = data.get('role_connections_verification_url')
def __repr__(self) -> str:
return f'<{self.__class__.__name__} id={self.id} name={self.name!r} description={self.description!r}>'
@property
def icon(self) -> Optional[Asset]:
"""Optional[:class:`.Asset`]: Retrieves the application's icon asset, if any."""
if self._icon is None:
return None
return Asset._from_icon(self._state, self.id, self._icon, path='app')
@property
def cover_image(self) -> Optional[Asset]:
"""Optional[:class:`.Asset`]: Retrieves the cover image of the application's default rich presence.
This is only available if the application is a game sold on Discord.
.. versionadded:: 2.3
"""
if self._cover_image is None:
return None
return Asset._from_cover_image(self._state, self.id, self._cover_image)
@property
def flags(self) -> ApplicationFlags:
""":class:`ApplicationFlags`: The application's flags.
.. versionadded:: 2.0
"""
return ApplicationFlags._from_value(self._flags)
class AppInstallParams:
"""Represents the settings for custom authorization URL of an application.
.. versionadded:: 2.0
Attributes
----------
scopes: List[:class:`str`]
The list of :ddocs:`OAuth2 scopes <topics/oauth2#shared-resources-oauth2-scopes>`
to add the application to a guild with.
permissions: :class:`Permissions`
The permissions to give to application in the guild.
"""
__slots__ = ('scopes', 'permissions')
def __init__(self, data: InstallParamsPayload) -> None:
self.scopes: List[str] = data.get('scopes', [])
self.permissions: Permissions = Permissions(int(data['permissions']))
class IntegrationTypeConfig:
"""Represents the default settings for the application's installation context.
.. versionadded:: 2.5
Attributes
----------
oauth2_install_params: Optional[:class:`AppInstallParams`]
The install params for this installation context's default in-app authorization link.
"""
def __init__(self, data: AppIntegrationTypeConfigPayload) -> None:
self.oauth2_install_params: Optional[AppInstallParams] = None
try:
self.oauth2_install_params = AppInstallParams(data['oauth2_install_params']) # type: ignore # EAFP
except KeyError:
pass

View File

@@ -0,0 +1,545 @@
"""
The MIT License (MIT)
Copyright (c) 2015-present Rapptz
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
import io
import os
from typing import Any, Literal, Optional, TYPE_CHECKING, Tuple, Union
from .errors import DiscordException
from . import utils
from .file import File
import yarl
# fmt: off
__all__ = (
'Asset',
)
# fmt: on
if TYPE_CHECKING:
from typing_extensions import Self
from .state import ConnectionState
from .webhook.async_ import _WebhookState
_State = Union[ConnectionState, _WebhookState]
ValidStaticFormatTypes = Literal['webp', 'jpeg', 'jpg', 'png']
ValidAssetFormatTypes = Literal['webp', 'jpeg', 'jpg', 'png', 'gif']
VALID_STATIC_FORMATS = frozenset({'jpeg', 'jpg', 'webp', 'png'})
VALID_ASSET_FORMATS = VALID_STATIC_FORMATS | {'gif'}
MISSING = utils.MISSING
class AssetMixin:
__slots__ = ()
url: str
_state: Optional[Any]
async def read(self) -> bytes:
"""|coro|
Retrieves the content of this asset as a :class:`bytes` object.
Raises
------
DiscordException
There was no internal connection state.
HTTPException
Downloading the asset failed.
NotFound
The asset was deleted.
Returns
-------
:class:`bytes`
The content of the asset.
"""
if self._state is None:
raise DiscordException('Invalid state (no ConnectionState provided)')
return await self._state.http.get_from_cdn(self.url)
async def save(self, fp: Union[str, bytes, os.PathLike[Any], io.BufferedIOBase], *, seek_begin: bool = True) -> int:
"""|coro|
Saves this asset into a file-like object.
Parameters
----------
fp: Union[:class:`io.BufferedIOBase`, :class:`os.PathLike`]
The file-like object to save this asset to or the filename
to use. If a filename is passed then a file is created with that
filename and used instead.
seek_begin: :class:`bool`
Whether to seek to the beginning of the file after saving is
successfully done.
Raises
------
DiscordException
There was no internal connection state.
HTTPException
Downloading the asset failed.
NotFound
The asset was deleted.
Returns
--------
:class:`int`
The number of bytes written.
"""
data = await self.read()
if isinstance(fp, io.BufferedIOBase):
written = fp.write(data)
if seek_begin:
fp.seek(0)
return written
else:
with open(fp, 'wb') as f:
return f.write(data)
async def to_file(
self,
*,
filename: Optional[str] = MISSING,
description: Optional[str] = None,
spoiler: bool = False,
) -> File:
"""|coro|
Converts the asset into a :class:`File` suitable for sending via
:meth:`abc.Messageable.send`.
.. versionadded:: 2.0
Parameters
-----------
filename: Optional[:class:`str`]
The filename of the file. If not provided, then the filename from
the asset's URL is used.
description: Optional[:class:`str`]
The description for the file.
spoiler: :class:`bool`
Whether the file is a spoiler.
Raises
------
DiscordException
The asset does not have an associated state.
ValueError
The asset is a unicode emoji.
TypeError
The asset is a sticker with lottie type.
HTTPException
Downloading the asset failed.
NotFound
The asset was deleted.
Returns
-------
:class:`File`
The asset as a file suitable for sending.
"""
data = await self.read()
file_filename = filename if filename is not MISSING else yarl.URL(self.url).name
return File(io.BytesIO(data), filename=file_filename, description=description, spoiler=spoiler)
class Asset(AssetMixin):
"""Represents a CDN asset on Discord.
.. container:: operations
.. describe:: str(x)
Returns the URL of the CDN asset.
.. describe:: len(x)
Returns the length of the CDN asset's URL.
.. describe:: x == y
Checks if the asset is equal to another asset.
.. describe:: x != y
Checks if the asset is not equal to another asset.
.. describe:: hash(x)
Returns the hash of the asset.
"""
__slots__: Tuple[str, ...] = (
'_state',
'_url',
'_animated',
'_key',
)
BASE = 'https://cdn.discordapp.com'
def __init__(self, state: _State, *, url: str, key: str, animated: bool = False) -> None:
self._state: _State = state
self._url: str = url
self._animated: bool = animated
self._key: str = key
@classmethod
def _from_default_avatar(cls, state: _State, index: int) -> Self:
return cls(
state,
url=f'{cls.BASE}/embed/avatars/{index}.png',
key=str(index),
animated=False,
)
@classmethod
def _from_avatar(cls, state: _State, user_id: int, avatar: str) -> Self:
animated = avatar.startswith('a_')
format = 'gif' if animated else 'png'
return cls(
state,
url=f'{cls.BASE}/avatars/{user_id}/{avatar}.{format}?size=1024',
key=avatar,
animated=animated,
)
@classmethod
def _from_guild_avatar(cls, state: _State, guild_id: int, member_id: int, avatar: str) -> Self:
animated = avatar.startswith('a_')
format = 'gif' if animated else 'png'
return cls(
state,
url=f'{cls.BASE}/guilds/{guild_id}/users/{member_id}/avatars/{avatar}.{format}?size=1024',
key=avatar,
animated=animated,
)
@classmethod
def _from_guild_banner(cls, state: _State, guild_id: int, member_id: int, banner: str) -> Self:
animated = banner.startswith('a_')
format = 'gif' if animated else 'png'
return cls(
state,
url=f'{cls.BASE}/guilds/{guild_id}/users/{member_id}/banners/{banner}.{format}?size=1024',
key=banner,
animated=animated,
)
@classmethod
def _from_avatar_decoration(cls, state: _State, avatar_decoration: str) -> Self:
return cls(
state,
url=f'{cls.BASE}/avatar-decoration-presets/{avatar_decoration}.png?size=96',
key=avatar_decoration,
animated=True,
)
@classmethod
def _from_icon(cls, state: _State, object_id: int, icon_hash: str, path: str) -> Self:
return cls(
state,
url=f'{cls.BASE}/{path}-icons/{object_id}/{icon_hash}.png?size=1024',
key=icon_hash,
animated=False,
)
@classmethod
def _from_app_icon(
cls, state: _State, object_id: int, icon_hash: str, asset_type: Literal['icon', 'cover_image']
) -> Self:
return cls(
state,
url=f'{cls.BASE}/app-icons/{object_id}/{asset_type}.png?size=1024',
key=icon_hash,
animated=False,
)
@classmethod
def _from_cover_image(cls, state: _State, object_id: int, cover_image_hash: str) -> Self:
return cls(
state,
url=f'{cls.BASE}/app-assets/{object_id}/store/{cover_image_hash}.png?size=1024',
key=cover_image_hash,
animated=False,
)
@classmethod
def _from_scheduled_event_cover_image(cls, state: _State, scheduled_event_id: int, cover_image_hash: str) -> Self:
return cls(
state,
url=f'{cls.BASE}/guild-events/{scheduled_event_id}/{cover_image_hash}.png?size=1024',
key=cover_image_hash,
animated=False,
)
@classmethod
def _from_guild_image(cls, state: _State, guild_id: int, image: str, path: str) -> Self:
animated = image.startswith('a_')
format = 'gif' if animated else 'png'
return cls(
state,
url=f'{cls.BASE}/{path}/{guild_id}/{image}.{format}?size=1024',
key=image,
animated=animated,
)
@classmethod
def _from_guild_icon(cls, state: _State, guild_id: int, icon_hash: str) -> Self:
animated = icon_hash.startswith('a_')
format = 'gif' if animated else 'png'
return cls(
state,
url=f'{cls.BASE}/icons/{guild_id}/{icon_hash}.{format}?size=1024',
key=icon_hash,
animated=animated,
)
@classmethod
def _from_sticker_banner(cls, state: _State, banner: int) -> Self:
return cls(
state,
url=f'{cls.BASE}/app-assets/710982414301790216/store/{banner}.png',
key=str(banner),
animated=False,
)
@classmethod
def _from_user_banner(cls, state: _State, user_id: int, banner_hash: str) -> Self:
animated = banner_hash.startswith('a_')
format = 'gif' if animated else 'png'
return cls(
state,
url=f'{cls.BASE}/banners/{user_id}/{banner_hash}.{format}?size=512',
key=banner_hash,
animated=animated,
)
@classmethod
def _from_primary_guild(cls, state: _State, guild_id: int, icon_hash: str) -> Self:
return cls(
state,
url=f'{cls.BASE}/guild-tag-badges/{guild_id}/{icon_hash}.png?size=64',
key=icon_hash,
animated=False,
)
def __str__(self) -> str:
return self._url
def __len__(self) -> int:
return len(self._url)
def __repr__(self) -> str:
shorten = self._url.replace(self.BASE, '')
return f'<Asset url={shorten!r}>'
def __eq__(self, other: object) -> bool:
return isinstance(other, Asset) and self._url == other._url
def __hash__(self) -> int:
return hash(self._url)
@property
def url(self) -> str:
""":class:`str`: Returns the underlying URL of the asset."""
return self._url
@property
def key(self) -> str:
""":class:`str`: Returns the identifying key of the asset."""
return self._key
def is_animated(self) -> bool:
""":class:`bool`: Returns whether the asset is animated."""
return self._animated
def replace(
self,
*,
size: int = MISSING,
format: ValidAssetFormatTypes = MISSING,
static_format: ValidStaticFormatTypes = MISSING,
) -> Self:
"""Returns a new asset with the passed components replaced.
.. versionchanged:: 2.0
``static_format`` is now preferred over ``format``
if both are present and the asset is not animated.
.. versionchanged:: 2.0
This function will now raise :exc:`ValueError` instead of
``InvalidArgument``.
Parameters
-----------
size: :class:`int`
The new size of the asset.
format: :class:`str`
The new format to change it to. Must be either
'webp', 'jpeg', 'jpg', 'png', or 'gif' if it's animated.
static_format: :class:`str`
The new format to change it to if the asset isn't animated.
Must be either 'webp', 'jpeg', 'jpg', or 'png'.
Raises
-------
ValueError
An invalid size or format was passed.
Returns
--------
:class:`Asset`
The newly updated asset.
"""
url = yarl.URL(self._url)
path, _ = os.path.splitext(url.path)
if format is not MISSING:
if self._animated:
if format not in VALID_ASSET_FORMATS:
raise ValueError(f'format must be one of {VALID_ASSET_FORMATS}')
else:
if static_format is MISSING and format not in VALID_STATIC_FORMATS:
raise ValueError(f'format must be one of {VALID_STATIC_FORMATS}')
url = url.with_path(f'{path}.{format}')
if static_format is not MISSING and not self._animated:
if static_format not in VALID_STATIC_FORMATS:
raise ValueError(f'static_format must be one of {VALID_STATIC_FORMATS}')
url = url.with_path(f'{path}.{static_format}')
if size is not MISSING:
if not utils.valid_icon_size(size):
raise ValueError('size must be a power of 2 between 16 and 4096')
url = url.with_query(size=size)
else:
url = url.with_query(url.raw_query_string)
url = str(url)
return self.__class__(state=self._state, url=url, key=self._key, animated=self._animated)
def with_size(self, size: int, /) -> Self:
"""Returns a new asset with the specified size.
.. versionchanged:: 2.0
This function will now raise :exc:`ValueError` instead of
``InvalidArgument``.
Parameters
------------
size: :class:`int`
The new size of the asset.
Raises
-------
ValueError
The asset had an invalid size.
Returns
--------
:class:`Asset`
The new updated asset.
"""
if not utils.valid_icon_size(size):
raise ValueError('size must be a power of 2 between 16 and 4096')
url = str(yarl.URL(self._url).with_query(size=size))
return self.__class__(state=self._state, url=url, key=self._key, animated=self._animated)
def with_format(self, format: ValidAssetFormatTypes, /) -> Self:
"""Returns a new asset with the specified format.
.. versionchanged:: 2.0
This function will now raise :exc:`ValueError` instead of
``InvalidArgument``.
Parameters
------------
format: :class:`str`
The new format of the asset.
Raises
-------
ValueError
The asset had an invalid format.
Returns
--------
:class:`Asset`
The new updated asset.
"""
if self._animated:
if format not in VALID_ASSET_FORMATS:
raise ValueError(f'format must be one of {VALID_ASSET_FORMATS}')
else:
if format not in VALID_STATIC_FORMATS:
raise ValueError(f'format must be one of {VALID_STATIC_FORMATS}')
url = yarl.URL(self._url)
path, _ = os.path.splitext(url.path)
url = str(url.with_path(f'{path}.{format}').with_query(url.raw_query_string))
return self.__class__(state=self._state, url=url, key=self._key, animated=self._animated)
def with_static_format(self, format: ValidStaticFormatTypes, /) -> Self:
"""Returns a new asset with the specified static format.
This only changes the format if the underlying asset is
not animated. Otherwise, the asset is not changed.
.. versionchanged:: 2.0
This function will now raise :exc:`ValueError` instead of
``InvalidArgument``.
Parameters
------------
format: :class:`str`
The new static format of the asset.
Raises
-------
ValueError
The asset had an invalid format.
Returns
--------
:class:`Asset`
The new updated asset.
"""
if self._animated:
return self
return self.with_format(format)

View File

@@ -0,0 +1,998 @@
"""
The MIT License (MIT)
Copyright (c) 2015-present Rapptz
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Mapping, Generator, List, Optional, Tuple, Type, TypeVar, Union
from . import enums, flags, utils
from .asset import Asset
from .colour import Colour
from .invite import Invite
from .mixins import Hashable
from .object import Object
from .permissions import PermissionOverwrite, Permissions
from .automod import AutoModTrigger, AutoModRuleAction, AutoModRule
from .role import Role
from .emoji import Emoji
from .partial_emoji import PartialEmoji
from .member import Member
from .scheduled_event import ScheduledEvent
from .stage_instance import StageInstance
from .sticker import GuildSticker
from .threads import Thread
from .integrations import PartialIntegration
from .channel import ForumChannel, StageChannel, ForumTag
from .onboarding import OnboardingPrompt, OnboardingPromptOption
__all__ = (
'AuditLogDiff',
'AuditLogChanges',
'AuditLogEntry',
)
if TYPE_CHECKING:
import datetime
from . import abc
from .guild import Guild
from .state import ConnectionState
from .types.audit_log import (
AuditLogChange as AuditLogChangePayload,
AuditLogEntry as AuditLogEntryPayload,
_AuditLogChange_TriggerMetadata as AuditLogChangeTriggerMetadataPayload,
)
from .types.channel import (
PermissionOverwrite as PermissionOverwritePayload,
ForumTag as ForumTagPayload,
DefaultReaction as DefaultReactionPayload,
)
from .types.invite import Invite as InvitePayload
from .types.role import Role as RolePayload, RoleColours
from .types.snowflake import Snowflake
from .types.command import ApplicationCommandPermissions
from .types.automod import AutoModerationAction
from .types.onboarding import Prompt as PromptPayload, PromptOption as PromptOptionPayload
from .user import User
from .app_commands import AppCommand
from .webhook import Webhook
TargetType = Union[
Guild,
abc.GuildChannel,
Member,
User,
Role,
Invite,
Emoji,
StageInstance,
GuildSticker,
Thread,
Object,
PartialIntegration,
AutoModRule,
ScheduledEvent,
Webhook,
AppCommand,
None,
]
def _transform_timestamp(entry: AuditLogEntry, data: Optional[str]) -> Optional[datetime.datetime]:
return utils.parse_time(data)
def _transform_color(entry: AuditLogEntry, data: int) -> Colour:
return Colour(data)
def _transform_snowflake(entry: AuditLogEntry, data: Snowflake) -> int:
return int(data)
def _transform_channel(entry: AuditLogEntry, data: Optional[Snowflake]) -> Optional[Union[abc.GuildChannel, Object]]:
if data is None:
return None
return entry.guild.get_channel(int(data)) or Object(id=data)
def _transform_channels_or_threads(
entry: AuditLogEntry, data: List[Snowflake]
) -> List[Union[abc.GuildChannel, Thread, Object]]:
return [entry.guild.get_channel_or_thread(int(data)) or Object(id=data) for data in data]
def _transform_member_id(entry: AuditLogEntry, data: Optional[Snowflake]) -> Union[Member, User, None]:
if data is None:
return None
return entry._get_member(int(data))
def _transform_guild_id(entry: AuditLogEntry, data: Optional[Snowflake]) -> Optional[Guild]:
if data is None:
return None
return entry._state._get_guild(int(data))
def _transform_roles(entry: AuditLogEntry, data: List[Snowflake]) -> List[Union[Role, Object]]:
return [entry.guild.get_role(int(role_id)) or Object(role_id, type=Role) for role_id in data]
def _transform_applied_forum_tags(entry: AuditLogEntry, data: List[Snowflake]) -> List[Union[ForumTag, Object]]:
thread = entry.target
if isinstance(thread, Thread) and isinstance(thread.parent, ForumChannel):
return [thread.parent.get_tag(tag_id) or Object(id=tag_id, type=ForumTag) for tag_id in map(int, data)]
return [Object(id=tag_id, type=ForumTag) for tag_id in data]
def _transform_overloaded_flags(entry: AuditLogEntry, data: int) -> Union[int, flags.ChannelFlags, flags.InviteFlags]:
# The `flags` key is definitely overloaded. Right now it's for channels, threads and invites but
# I am aware of `member.flags` and `user.flags` existing. However, this does not impact audit logs
# at the moment but better safe than sorry.
channel_audit_log_types = (
enums.AuditLogAction.channel_create,
enums.AuditLogAction.channel_update,
enums.AuditLogAction.channel_delete,
enums.AuditLogAction.thread_create,
enums.AuditLogAction.thread_update,
enums.AuditLogAction.thread_delete,
)
invite_audit_log_types = (
enums.AuditLogAction.invite_create,
enums.AuditLogAction.invite_update,
enums.AuditLogAction.invite_delete,
)
if entry.action in channel_audit_log_types:
return flags.ChannelFlags._from_value(data)
elif entry.action in invite_audit_log_types:
return flags.InviteFlags._from_value(data)
return data
def _transform_forum_tags(entry: AuditLogEntry, data: List[ForumTagPayload]) -> List[ForumTag]:
return [ForumTag.from_data(state=entry._state, data=d) for d in data]
def _transform_default_reaction(entry: AuditLogEntry, data: DefaultReactionPayload) -> Optional[PartialEmoji]:
if data is None:
return None
emoji_name = data.get('emoji_name') or ''
emoji_id = utils._get_as_snowflake(data, 'emoji_id') or None # Coerce 0 -> None
return PartialEmoji.with_state(state=entry._state, name=emoji_name, id=emoji_id)
def _transform_overwrites(
entry: AuditLogEntry, data: List[PermissionOverwritePayload]
) -> List[Tuple[Object, PermissionOverwrite]]:
overwrites = []
for elem in data:
allow = Permissions(int(elem['allow']))
deny = Permissions(int(elem['deny']))
ow = PermissionOverwrite.from_pair(allow, deny)
ow_type = elem['type']
ow_id = int(elem['id'])
target = None
if ow_type == '0':
target = entry.guild.get_role(ow_id)
elif ow_type == '1':
target = entry._get_member(ow_id)
if target is None:
target = Object(id=ow_id, type=Role if ow_type == '0' else Member)
overwrites.append((target, ow))
return overwrites
def _transform_icon(entry: AuditLogEntry, data: Optional[str]) -> Optional[Asset]:
if data is None:
return None
if entry.action is enums.AuditLogAction.guild_update:
return Asset._from_guild_icon(entry._state, entry.guild.id, data)
else:
return Asset._from_icon(entry._state, entry._target_id, data, path='role') # type: ignore # target_id won't be None in this case
def _transform_avatar(entry: AuditLogEntry, data: Optional[str]) -> Optional[Asset]:
if data is None:
return None
return Asset._from_avatar(entry._state, entry._target_id, data) # type: ignore # target_id won't be None in this case
def _transform_cover_image(entry: AuditLogEntry, data: Optional[str]) -> Optional[Asset]:
if data is None:
return None
return Asset._from_scheduled_event_cover_image(entry._state, entry._target_id, data) # type: ignore # target_id won't be None in this case
def _guild_hash_transformer(path: str) -> Callable[[AuditLogEntry, Optional[str]], Optional[Asset]]:
def _transform(entry: AuditLogEntry, data: Optional[str]) -> Optional[Asset]:
if data is None:
return None
return Asset._from_guild_image(entry._state, entry.guild.id, data, path=path)
return _transform
def _transform_automod_actions(entry: AuditLogEntry, data: List[AutoModerationAction]) -> List[AutoModRuleAction]:
return [AutoModRuleAction.from_data(action) for action in data]
def _transform_default_emoji(entry: AuditLogEntry, data: str) -> PartialEmoji:
return PartialEmoji(name=data)
def _transform_onboarding_prompts(entry: AuditLogEntry, data: List[PromptPayload]) -> List[OnboardingPrompt]:
return [OnboardingPrompt.from_dict(data=prompt, state=entry._state, guild=entry.guild) for prompt in data]
def _transform_onboarding_prompt_options(
entry: AuditLogEntry, data: List[PromptOptionPayload]
) -> List[OnboardingPromptOption]:
return [OnboardingPromptOption.from_dict(data=option, state=entry._state, guild=entry.guild) for option in data]
E = TypeVar('E', bound=enums.Enum)
def _enum_transformer(enum: Type[E]) -> Callable[[AuditLogEntry, int], E]:
def _transform(entry: AuditLogEntry, data: int) -> E:
return enums.try_enum(enum, data)
return _transform
F = TypeVar('F', bound=flags.BaseFlags)
def _flag_transformer(cls: Type[F]) -> Callable[[AuditLogEntry, Union[int, str]], F]:
def _transform(entry: AuditLogEntry, data: Union[int, str]) -> F:
return cls._from_value(int(data))
return _transform
def _transform_type(
entry: AuditLogEntry, data: Union[int, str]
) -> Union[enums.ChannelType, enums.StickerType, enums.WebhookType, str, enums.OnboardingPromptType]:
if entry.action.name.startswith('sticker_'):
return enums.try_enum(enums.StickerType, data)
elif entry.action.name.startswith('integration_'):
return data # type: ignore # integration type is str
elif entry.action.name.startswith('webhook_'):
return enums.try_enum(enums.WebhookType, data)
elif entry.action.name.startswith('onboarding_prompt_'):
return enums.try_enum(enums.OnboardingPromptType, data)
else:
return enums.try_enum(enums.ChannelType, data)
class AuditLogDiff:
def __len__(self) -> int:
return len(self.__dict__)
def __iter__(self) -> Generator[Tuple[str, Any], None, None]:
yield from self.__dict__.items()
def __repr__(self) -> str:
values = ' '.join('%s=%r' % item for item in self.__dict__.items())
return f'<AuditLogDiff {values}>'
if TYPE_CHECKING:
def __getattr__(self, item: str) -> Any: ...
def __setattr__(self, key: str, value: Any) -> Any: ...
Transformer = Callable[['AuditLogEntry', Any], Any]
class AuditLogChanges:
# fmt: off
TRANSFORMERS: ClassVar[Mapping[str, Tuple[Optional[str], Optional[Transformer]]]] = {
'verification_level': (None, _enum_transformer(enums.VerificationLevel)),
'explicit_content_filter': (None, _enum_transformer(enums.ContentFilter)),
'allow': (None, _flag_transformer(Permissions)),
'deny': (None, _flag_transformer(Permissions)),
'permissions': (None, _flag_transformer(Permissions)),
'id': (None, _transform_snowflake),
'color': ('colour', _transform_color),
'owner_id': ('owner', _transform_member_id),
'inviter_id': ('inviter', _transform_member_id),
'channel_id': ('channel', _transform_channel),
'afk_channel_id': ('afk_channel', _transform_channel),
'system_channel_id': ('system_channel', _transform_channel),
'system_channel_flags': (None, _flag_transformer(flags.SystemChannelFlags)),
'widget_channel_id': ('widget_channel', _transform_channel),
'rules_channel_id': ('rules_channel', _transform_channel),
'public_updates_channel_id': ('public_updates_channel', _transform_channel),
'permission_overwrites': ('overwrites', _transform_overwrites),
'splash_hash': ('splash', _guild_hash_transformer('splashes')),
'banner_hash': ('banner', _guild_hash_transformer('banners')),
'discovery_splash_hash': ('discovery_splash', _guild_hash_transformer('discovery-splashes')),
'icon_hash': ('icon', _transform_icon),
'avatar_hash': ('avatar', _transform_avatar),
'rate_limit_per_user': ('slowmode_delay', None),
'default_thread_rate_limit_per_user': ('default_thread_slowmode_delay', None),
'guild_id': ('guild', _transform_guild_id),
'tags': ('emoji', None),
'default_message_notifications': ('default_notifications', _enum_transformer(enums.NotificationLevel)),
'video_quality_mode': (None, _enum_transformer(enums.VideoQualityMode)),
'privacy_level': (None, _enum_transformer(enums.PrivacyLevel)),
'format_type': (None, _enum_transformer(enums.StickerFormatType)),
'type': (None, _transform_type),
'communication_disabled_until': ('timed_out_until', _transform_timestamp),
'expire_behavior': (None, _enum_transformer(enums.ExpireBehaviour)),
'mfa_level': (None, _enum_transformer(enums.MFALevel)),
'status': (None, _enum_transformer(enums.EventStatus)),
'entity_type': (None, _enum_transformer(enums.EntityType)),
'preferred_locale': (None, _enum_transformer(enums.Locale)),
'image_hash': ('cover_image', _transform_cover_image),
'trigger_type': (None, _enum_transformer(enums.AutoModRuleTriggerType)),
'event_type': (None, _enum_transformer(enums.AutoModRuleEventType)),
'actions': (None, _transform_automod_actions),
'exempt_channels': (None, _transform_channels_or_threads),
'exempt_roles': (None, _transform_roles),
'applied_tags': (None, _transform_applied_forum_tags),
'available_tags': (None, _transform_forum_tags),
'flags': (None, _transform_overloaded_flags),
'default_reaction_emoji': (None, _transform_default_reaction),
'emoji_name': ('emoji', _transform_default_emoji),
'user_id': ('user', _transform_member_id),
'options': (None, _transform_onboarding_prompt_options),
'prompts': (None, _transform_onboarding_prompts),
'default_channel_ids': ('default_channels', _transform_channels_or_threads),
'mode': (None, _enum_transformer(enums.OnboardingMode)),
}
# fmt: on
def __init__(self, entry: AuditLogEntry, data: List[AuditLogChangePayload]):
self.before: AuditLogDiff = AuditLogDiff()
self.after: AuditLogDiff = AuditLogDiff()
# special case entire process since each
# element in data is a different target
# key is the target id
if entry.action is enums.AuditLogAction.app_command_permission_update:
self.before.app_command_permissions = []
self.after.app_command_permissions = []
for elem in data:
self._handle_app_command_permissions(
self.before,
entry,
elem.get('old_value'), # type: ignore # value will be an ApplicationCommandPermissions if present
)
self._handle_app_command_permissions(
self.after,
entry,
elem.get('new_value'), # type: ignore # value will be an ApplicationCommandPermissions if present
)
return
for elem in data:
attr = elem['key']
# special cases for role add/remove
if attr == '$add':
self._handle_role(self.before, self.after, entry, elem['new_value']) # type: ignore # new_value is a list of roles in this case
continue
elif attr == '$remove':
self._handle_role(self.after, self.before, entry, elem['new_value']) # type: ignore # new_value is a list of roles in this case
continue
# special case for automod trigger
if attr == 'trigger_metadata':
# given full metadata dict
self._handle_trigger_metadata(entry, elem, data) # type: ignore # should be trigger metadata
continue
elif entry.action is enums.AuditLogAction.automod_rule_update and attr.startswith('$'):
# on update, some trigger attributes are keys and formatted as $(add/remove)_{attribute}
action, _, trigger_attr = attr.partition('_')
# new_value should be a list of added/removed strings for keyword_filter, regex_patterns, or allow_list
if action == '$add':
self._handle_trigger_attr_update(self.before, self.after, entry, trigger_attr, elem['new_value']) # type: ignore
elif action == '$remove':
self._handle_trigger_attr_update(self.after, self.before, entry, trigger_attr, elem['new_value']) # type: ignore
continue
# special case for colors to set secondary and tertiary colos/colour attributes
if attr == 'colors':
self._handle_colours(self.before, elem.get('old_value')) # type: ignore # should be a RoleColours dict
self._handle_colours(self.after, elem.get('new_value')) # type: ignore # should be a RoleColours dict
continue
try:
key, transformer = self.TRANSFORMERS[attr]
except (ValueError, KeyError):
transformer = None
else:
if key:
attr = key
transformer: Optional[Transformer]
try:
before = elem['old_value']
except KeyError:
before = None
else:
if transformer:
before = transformer(entry, before)
setattr(self.before, attr, before)
try:
after = elem['new_value']
except KeyError:
after = None
else:
if transformer:
after = transformer(entry, after)
setattr(self.after, attr, after)
# add an alias
if hasattr(self.after, 'colour'):
self.after.color = self.after.colour
self.before.color = self.before.colour
if hasattr(self.after, 'expire_behavior'):
self.after.expire_behaviour = self.after.expire_behavior
self.before.expire_behaviour = self.before.expire_behavior
def __repr__(self) -> str:
return f'<AuditLogChanges before={self.before!r} after={self.after!r}>'
def _handle_role(self, first: AuditLogDiff, second: AuditLogDiff, entry: AuditLogEntry, elem: List[RolePayload]) -> None:
if not hasattr(first, 'roles'):
setattr(first, 'roles', [])
data = []
g: Guild = entry.guild
for e in elem:
role_id = int(e['id'])
role = g.get_role(role_id)
if role is None:
role = Object(id=role_id, type=Role)
role.name = e['name'] # type: ignore # Object doesn't usually have name
data.append(role)
setattr(second, 'roles', data)
def _handle_app_command_permissions(
self,
diff: AuditLogDiff,
entry: AuditLogEntry,
data: Optional[ApplicationCommandPermissions],
):
if data is None:
return
# avoid circular import
from discord.app_commands import AppCommandPermissions
state = entry._state
guild = entry.guild
diff.app_command_permissions.append(AppCommandPermissions(data=data, guild=guild, state=state))
def _handle_trigger_metadata(
self,
entry: AuditLogEntry,
data: AuditLogChangeTriggerMetadataPayload,
full_data: List[AuditLogChangePayload],
):
trigger_value: Optional[int] = None
trigger_type: Optional[enums.AutoModRuleTriggerType] = None
# try to get trigger type from before or after
trigger_type = getattr(self.before, 'trigger_type', getattr(self.after, 'trigger_type', None))
if trigger_type is None:
if isinstance(entry.target, AutoModRule):
# Trigger type cannot be changed, so it should be the same before and after updates.
# Avoids checking which keys are in data to guess trigger type
trigger_value = entry.target.trigger.type.value
else:
# found a trigger type from before or after
trigger_value = trigger_type.value
if trigger_value is None:
# try to find trigger type in the full list of changes
_elem = utils.find(lambda elem: elem['key'] == 'trigger_type', full_data)
if _elem is not None:
trigger_value = _elem.get('old_value', _elem.get('new_value')) # type: ignore # trigger type values should be int
if trigger_value is None:
# try to infer trigger_type from the keys in old or new value
combined = (data.get('old_value') or {}).keys() | (data.get('new_value') or {}).keys()
if not combined:
trigger_value = enums.AutoModRuleTriggerType.spam.value
elif 'presets' in combined:
trigger_value = enums.AutoModRuleTriggerType.keyword_preset.value
elif 'keyword_filter' in combined or 'regex_patterns' in combined:
trigger_value = enums.AutoModRuleTriggerType.keyword.value
elif 'mention_total_limit' in combined or 'mention_raid_protection_enabled' in combined:
trigger_value = enums.AutoModRuleTriggerType.mention_spam.value
else:
# some unknown type
trigger_value = -1
self.before.trigger = AutoModTrigger.from_data(trigger_value, data.get('old_value'))
self.after.trigger = AutoModTrigger.from_data(trigger_value, data.get('new_value'))
def _handle_trigger_attr_update(
self, first: AuditLogDiff, second: AuditLogDiff, entry: AuditLogEntry, attr: str, data: List[str]
):
self._create_trigger(first, entry)
trigger = self._create_trigger(second, entry)
try:
# guard unexpecte non list attributes or non iterable data
getattr(trigger, attr).extend(data)
except (AttributeError, TypeError):
pass
def _handle_colours(self, diff: AuditLogDiff, colours: Optional[RoleColours]):
if colours is not None:
# handle colours to multiple colour attributes
colour = Colour(colours['primary_color'])
secondary_colour = colours['secondary_color']
tertiary_colour = colours['tertiary_color']
else:
colour = None
secondary_colour = None
tertiary_colour = None
diff.color = diff.colour = colour
diff.secondary_color = diff.secondary_colour = Colour(secondary_colour) if secondary_colour is not None else None
diff.tertiary_color = diff.tertiary_colour = Colour(tertiary_colour) if tertiary_colour is not None else None
def _create_trigger(self, diff: AuditLogDiff, entry: AuditLogEntry) -> AutoModTrigger:
# check if trigger has already been created
if not hasattr(diff, 'trigger'):
# create a trigger
if isinstance(entry.target, AutoModRule):
# get trigger type from the automod rule
trigger_type = entry.target.trigger.type
else:
# unknown trigger type
trigger_type = enums.try_enum(enums.AutoModRuleTriggerType, -1)
diff.trigger = AutoModTrigger(type=trigger_type)
return diff.trigger
class _AuditLogProxy:
def __init__(self, **kwargs: Any) -> None:
for k, v in kwargs.items():
setattr(self, k, v)
class _AuditLogProxyMemberPrune(_AuditLogProxy):
delete_member_days: int
members_removed: int
class _AuditLogProxyMemberMoveOrMessageDelete(_AuditLogProxy):
channel: Union[abc.GuildChannel, Thread]
count: int
class _AuditLogProxyMemberDisconnect(_AuditLogProxy):
count: int
class _AuditLogProxyPinAction(_AuditLogProxy):
channel: Union[abc.GuildChannel, Thread]
message_id: int
class _AuditLogProxyStageInstanceAction(_AuditLogProxy):
channel: abc.GuildChannel
class _AuditLogProxyMessageBulkDelete(_AuditLogProxy):
count: int
class _AuditLogProxyAutoModAction(_AuditLogProxy):
automod_rule_name: str
automod_rule_trigger_type: str
channel: Optional[Union[abc.GuildChannel, Thread]]
class _AuditLogProxyAutoModActionQuarantineUser(_AuditLogProxy):
automod_rule_name: str
automod_rule_trigger_type: str
class _AuditLogProxyMemberKickOrMemberRoleUpdate(_AuditLogProxy):
integration_type: Optional[str]
class AuditLogEntry(Hashable):
r"""Represents an Audit Log entry.
You retrieve these via :meth:`Guild.audit_logs`.
.. container:: operations
.. describe:: x == y
Checks if two entries are equal.
.. describe:: x != y
Checks if two entries are not equal.
.. describe:: hash(x)
Returns the entry's hash.
.. versionchanged:: 1.7
Audit log entries are now comparable and hashable.
Attributes
-----------
action: :class:`AuditLogAction`
The action that was done.
user: Optional[:class:`abc.User`]
The user who initiated this action. Usually a :class:`Member`\, unless gone
then it's a :class:`User`.
user_id: Optional[:class:`int`]
The user ID who initiated this action.
.. versionadded:: 2.2
id: :class:`int`
The entry ID.
guild: :class:`Guild`
The guild that this entry belongs to.
target: Any
The target that got changed. The exact type of this depends on
the action being done.
reason: Optional[:class:`str`]
The reason this action was done.
extra: Any
Extra information that this entry has that might be useful.
For most actions, this is ``None``. However in some cases it
contains extra information. See :class:`AuditLogAction` for
which actions have this field filled out.
"""
def __init__(
self,
*,
users: Mapping[int, User],
integrations: Mapping[int, PartialIntegration],
app_commands: Mapping[int, AppCommand],
automod_rules: Mapping[int, AutoModRule],
webhooks: Mapping[int, Webhook],
data: AuditLogEntryPayload,
guild: Guild,
):
self._state: ConnectionState = guild._state
self.guild: Guild = guild
self._users: Mapping[int, User] = users
self._integrations: Mapping[int, PartialIntegration] = integrations
self._app_commands: Mapping[int, AppCommand] = app_commands
self._automod_rules: Mapping[int, AutoModRule] = automod_rules
self._webhooks: Mapping[int, Webhook] = webhooks
self._from_data(data)
def _from_data(self, data: AuditLogEntryPayload) -> None:
self.action: enums.AuditLogAction = enums.try_enum(enums.AuditLogAction, data['action_type'])
self.id: int = int(data['id'])
# this key is technically not usually present
self.reason: Optional[str] = data.get('reason')
extra = data.get('options')
# fmt: off
self.extra: Union[
_AuditLogProxyMemberPrune,
_AuditLogProxyMemberMoveOrMessageDelete,
_AuditLogProxyMemberDisconnect,
_AuditLogProxyPinAction,
_AuditLogProxyStageInstanceAction,
_AuditLogProxyMessageBulkDelete,
_AuditLogProxyAutoModAction,
_AuditLogProxyAutoModActionQuarantineUser,
_AuditLogProxyMemberKickOrMemberRoleUpdate,
Member, User, None, PartialIntegration,
Role, Object
] = None
# fmt: on
if isinstance(self.action, enums.AuditLogAction) and extra:
if self.action is enums.AuditLogAction.member_prune:
# member prune has two keys with useful information
self.extra = _AuditLogProxyMemberPrune(
delete_member_days=int(extra['delete_member_days']),
members_removed=int(extra['members_removed']),
)
elif self.action is enums.AuditLogAction.member_move or self.action is enums.AuditLogAction.message_delete:
channel_id = int(extra['channel_id'])
self.extra = _AuditLogProxyMemberMoveOrMessageDelete(
count=int(extra['count']),
channel=self.guild.get_channel_or_thread(channel_id) or Object(id=channel_id),
)
elif self.action is enums.AuditLogAction.member_disconnect:
# The member disconnect action has a dict with some information
self.extra = _AuditLogProxyMemberDisconnect(count=int(extra['count']))
elif self.action is enums.AuditLogAction.message_bulk_delete:
# The bulk message delete action has the number of messages deleted
self.extra = _AuditLogProxyMessageBulkDelete(count=int(extra['count']))
elif self.action in (enums.AuditLogAction.kick, enums.AuditLogAction.member_role_update):
# The member kick action has a dict with some information
integration_type = extra.get('integration_type')
self.extra = _AuditLogProxyMemberKickOrMemberRoleUpdate(integration_type=integration_type)
elif self.action.name.endswith('pin'):
# the pin actions have a dict with some information
channel_id = int(extra['channel_id'])
self.extra = _AuditLogProxyPinAction(
channel=self.guild.get_channel_or_thread(channel_id) or Object(id=channel_id),
message_id=int(extra['message_id']),
)
elif (
self.action is enums.AuditLogAction.automod_block_message
or self.action is enums.AuditLogAction.automod_flag_message
or self.action is enums.AuditLogAction.automod_timeout_member
):
channel_id = utils._get_as_snowflake(extra, 'channel_id')
channel = None
# May be an empty string instead of None due to a Discord issue
if channel_id:
channel = self.guild.get_channel_or_thread(channel_id) or Object(id=channel_id)
self.extra = _AuditLogProxyAutoModAction(
automod_rule_name=extra['auto_moderation_rule_name'],
automod_rule_trigger_type=enums.try_enum(
enums.AutoModRuleTriggerType, int(extra['auto_moderation_rule_trigger_type'])
),
channel=channel,
)
elif self.action is enums.AuditLogAction.automod_quarantine_user:
self.extra = _AuditLogProxyAutoModActionQuarantineUser(
automod_rule_name=extra['auto_moderation_rule_name'],
automod_rule_trigger_type=enums.try_enum(
enums.AutoModRuleTriggerType, int(extra['auto_moderation_rule_trigger_type'])
),
)
elif self.action.name.startswith('overwrite_'):
# the overwrite_ actions have a dict with some information
instance_id = int(extra['id'])
the_type = extra.get('type')
if the_type == '1':
self.extra = self._get_member(instance_id)
elif the_type == '0':
role = self.guild.get_role(instance_id)
if role is None:
role = Object(id=instance_id, type=Role)
role.name = extra.get('role_name') # type: ignore # Object doesn't usually have name
self.extra = role
elif self.action.name.startswith('stage_instance'):
channel_id = int(extra['channel_id'])
self.extra = _AuditLogProxyStageInstanceAction(
channel=self.guild.get_channel(channel_id) or Object(id=channel_id, type=StageChannel)
)
elif self.action.name.startswith('app_command'):
app_id = int(extra['application_id'])
self.extra = self._get_integration_by_app_id(app_id) or Object(app_id, type=PartialIntegration)
# this key is not present when the above is present, typically.
# It's a list of { new_value: a, old_value: b, key: c }
# where new_value and old_value are not guaranteed to be there depending
# on the action type, so let's just fetch it for now and only turn it
# into meaningful data when requested
self._changes = data.get('changes', [])
self.user_id: Optional[int] = utils._get_as_snowflake(data, 'user_id')
self.user: Optional[Union[User, Member]] = self._get_member(self.user_id)
self._target_id = utils._get_as_snowflake(data, 'target_id')
def _get_member(self, user_id: Optional[int]) -> Union[Member, User, None]:
if user_id is None:
return None
return self.guild.get_member(user_id) or self._users.get(user_id)
def _get_integration(self, integration_id: Optional[int]) -> Optional[PartialIntegration]:
if integration_id is None:
return None
return self._integrations.get(integration_id)
def _get_integration_by_app_id(self, application_id: Optional[int]) -> Optional[PartialIntegration]:
if application_id is None:
return None
# get PartialIntegration by application id
return utils.get(self._integrations.values(), application_id=application_id)
def _get_app_command(self, app_command_id: Optional[int]) -> Optional[AppCommand]:
if app_command_id is None:
return None
return self._app_commands.get(app_command_id)
def __repr__(self) -> str:
return f'<AuditLogEntry id={self.id} action={self.action} user={self.user!r}>'
@utils.cached_property
def created_at(self) -> datetime.datetime:
""":class:`datetime.datetime`: Returns the entry's creation time in UTC."""
return utils.snowflake_time(self.id)
@utils.cached_property
def target(self) -> TargetType:
if self.action.target_type is None:
return None
try:
converter = getattr(self, '_convert_target_' + self.action.target_type)
except AttributeError:
if self._target_id is None:
return None
return Object(id=self._target_id)
else:
return converter(self._target_id)
@utils.cached_property
def category(self) -> Optional[enums.AuditLogActionCategory]:
"""Optional[:class:`AuditLogActionCategory`]: The category of the action, if applicable."""
return self.action.category
@utils.cached_property
def changes(self) -> AuditLogChanges:
""":class:`AuditLogChanges`: The list of changes this entry has."""
obj = AuditLogChanges(self, self._changes)
del self._changes
return obj
@utils.cached_property
def before(self) -> AuditLogDiff:
""":class:`AuditLogDiff`: The target's prior state."""
return self.changes.before
@utils.cached_property
def after(self) -> AuditLogDiff:
""":class:`AuditLogDiff`: The target's subsequent state."""
return self.changes.after
def _convert_target_guild(self, target_id: int) -> Guild:
return self.guild
def _convert_target_channel(self, target_id: int) -> Union[abc.GuildChannel, Object]:
return self.guild.get_channel(target_id) or Object(id=target_id)
def _convert_target_user(self, target_id: Optional[int]) -> Optional[Union[Member, User, Object]]:
# For some reason the member_disconnect and member_move action types
# do not have a non-null target_id so safeguard against that
if target_id is None:
return None
return self._get_member(target_id) or Object(id=target_id, type=Member)
def _convert_target_role(self, target_id: int) -> Union[Role, Object]:
return self.guild.get_role(target_id) or Object(id=target_id, type=Role)
def _convert_target_invite(self, target_id: None) -> Invite:
# invites have target_id set to null
# so figure out which change has the full invite data
changeset = self.before if self.action is enums.AuditLogAction.invite_delete else self.after
fake_payload: InvitePayload = {
'max_age': changeset.max_age,
'max_uses': changeset.max_uses,
'code': changeset.code,
'temporary': changeset.temporary,
'uses': changeset.uses,
'channel': None, # type: ignore # the channel is passed to the Invite constructor directly
}
obj = Invite(state=self._state, data=fake_payload, guild=self.guild, channel=changeset.channel)
try:
obj.inviter = changeset.inviter
except AttributeError:
pass
return obj
def _convert_target_emoji(self, target_id: int) -> Union[Emoji, Object]:
return self._state.get_emoji(target_id) or Object(id=target_id, type=Emoji)
def _convert_target_message(self, target_id: Optional[int]) -> Optional[Union[Member, User, Object]]:
# The message_pin and message_unpin action types do not have a
# non-null target_id so safeguard against that
if target_id is None:
return None
return self._get_member(target_id) or Object(id=target_id, type=Member)
def _convert_target_stage_instance(self, target_id: int) -> Union[StageInstance, Object]:
return self.guild.get_stage_instance(target_id) or Object(id=target_id, type=StageInstance)
def _convert_target_sticker(self, target_id: int) -> Union[GuildSticker, Object]:
return self._state.get_sticker(target_id) or Object(id=target_id, type=GuildSticker)
def _convert_target_thread(self, target_id: int) -> Union[Thread, Object]:
return self.guild.get_thread(target_id) or Object(id=target_id, type=Thread)
def _convert_target_guild_scheduled_event(self, target_id: int) -> Union[ScheduledEvent, Object]:
return self.guild.get_scheduled_event(target_id) or Object(id=target_id, type=ScheduledEvent)
def _convert_target_integration(self, target_id: int) -> Union[PartialIntegration, Object]:
return self._get_integration(target_id) or Object(target_id, type=PartialIntegration)
def _convert_target_app_command(self, target_id: int) -> Union[AppCommand, Object]:
target = self._get_app_command(target_id)
if not target:
# circular import
from .app_commands import AppCommand
target = Object(target_id, type=AppCommand)
return target
def _convert_target_integration_or_app_command(self, target_id: int) -> Union[PartialIntegration, AppCommand, Object]:
target = self._get_integration_by_app_id(target_id) or self._get_app_command(target_id)
if not target:
try:
# circular import
from .app_commands import AppCommand
# get application id from extras
# if it matches target id, type should be integration
target_app = self.extra
# extra should be an Object or PartialIntegration
app_id = target_app.application_id if isinstance(target_app, PartialIntegration) else target_app.id # type: ignore
type = PartialIntegration if target_id == app_id else AppCommand
except AttributeError:
return Object(target_id)
else:
return Object(target_id, type=type)
return target
def _convert_target_auto_moderation(self, target_id: int) -> Union[AutoModRule, Object]:
return self._automod_rules.get(target_id) or Object(target_id, type=AutoModRule)
def _convert_target_webhook(self, target_id: int) -> Union[Webhook, Object]:
# circular import
from .webhook import Webhook
return self._webhooks.get(target_id) or Object(target_id, type=Webhook)
def _convert_target_onboarding_prompt(self, target_id: int) -> Object:
return Object(target_id, type=OnboardingPrompt)

View File

@@ -0,0 +1,666 @@
"""
The MIT License (MIT)
Copyright (c) 2015-present Rapptz
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
import datetime
from typing import TYPE_CHECKING, Any, Dict, Optional, List, Set, Union, Sequence, overload, Literal
from .enums import AutoModRuleTriggerType, AutoModRuleActionType, AutoModRuleEventType, try_enum
from .flags import AutoModPresets
from . import utils
from .utils import MISSING, cached_slot_property
if TYPE_CHECKING:
from typing_extensions import Self
from .abc import Snowflake, GuildChannel
from .threads import Thread
from .guild import Guild
from .member import Member
from .state import ConnectionState
from .types.automod import (
AutoModerationRule as AutoModerationRulePayload,
AutoModerationTriggerMetadata as AutoModerationTriggerMetadataPayload,
AutoModerationAction as AutoModerationActionPayload,
AutoModerationActionExecution as AutoModerationActionExecutionPayload,
)
from .role import Role
__all__ = (
'AutoModRuleAction',
'AutoModTrigger',
'AutoModRule',
'AutoModAction',
)
class AutoModRuleAction:
"""Represents an auto moderation's rule action.
.. note::
Only one of ``channel_id``, ``duration``, or ``custom_message`` can be used.
.. versionadded:: 2.0
Attributes
-----------
type: :class:`AutoModRuleActionType`
The type of action to take.
Defaults to :attr:`~AutoModRuleActionType.block_message`.
channel_id: Optional[:class:`int`]
The ID of the channel or thread to send the alert message to, if any.
Passing this sets :attr:`type` to :attr:`~AutoModRuleActionType.send_alert_message`.
duration: Optional[:class:`datetime.timedelta`]
The duration of the timeout to apply, if any.
Has a maximum of 28 days.
Passing this sets :attr:`type` to :attr:`~AutoModRuleActionType.timeout`.
custom_message: Optional[:class:`str`]
A custom message which will be shown to a user when their message is blocked.
Passing this sets :attr:`type` to :attr:`~AutoModRuleActionType.block_message`.
.. versionadded:: 2.2
"""
__slots__ = ('type', 'channel_id', 'duration', 'custom_message')
@overload
def __init__(self, *, channel_id: int = ...) -> None: ...
@overload
def __init__(self, *, type: Literal[AutoModRuleActionType.send_alert_message], channel_id: int = ...) -> None: ...
@overload
def __init__(self, *, duration: datetime.timedelta = ...) -> None: ...
@overload
def __init__(self, *, type: Literal[AutoModRuleActionType.timeout], duration: datetime.timedelta = ...) -> None: ...
@overload
def __init__(self, *, custom_message: str = ...) -> None: ...
@overload
def __init__(self, *, type: Literal[AutoModRuleActionType.block_message]) -> None: ...
@overload
def __init__(
self, *, type: Literal[AutoModRuleActionType.block_message], custom_message: Optional[str] = ...
) -> None: ...
@overload
def __init__(
self,
*,
type: Optional[AutoModRuleActionType] = ...,
channel_id: Optional[int] = ...,
duration: Optional[datetime.timedelta] = ...,
custom_message: Optional[str] = ...,
) -> None: ...
def __init__(
self,
*,
type: Optional[AutoModRuleActionType] = None,
channel_id: Optional[int] = None,
duration: Optional[datetime.timedelta] = None,
custom_message: Optional[str] = None,
) -> None:
if sum(v is None for v in (channel_id, duration, custom_message)) < 2:
raise ValueError('Only one of channel_id, duration, or custom_message can be passed.')
self.type: AutoModRuleActionType
self.channel_id: Optional[int] = None
self.duration: Optional[datetime.timedelta] = None
self.custom_message: Optional[str] = None
if type is not None:
self.type = type
elif channel_id is not None:
self.type = AutoModRuleActionType.send_alert_message
elif duration is not None:
self.type = AutoModRuleActionType.timeout
else:
self.type = AutoModRuleActionType.block_message
if self.type is AutoModRuleActionType.send_alert_message:
if channel_id is None:
raise ValueError('channel_id cannot be None if type is send_alert_message')
self.channel_id = channel_id
if self.type is AutoModRuleActionType.timeout:
if duration is None:
raise ValueError('duration cannot be None set if type is timeout')
self.duration = duration
if self.type is AutoModRuleActionType.block_message:
self.custom_message = custom_message
def __repr__(self) -> str:
return f'<AutoModRuleAction type={self.type.value} channel={self.channel_id} duration={self.duration}>'
@classmethod
def from_data(cls, data: AutoModerationActionPayload) -> Self:
if data['type'] == AutoModRuleActionType.timeout.value:
duration_seconds = data['metadata']['duration_seconds']
return cls(duration=datetime.timedelta(seconds=duration_seconds))
elif data['type'] == AutoModRuleActionType.send_alert_message.value:
channel_id = int(data['metadata']['channel_id'])
return cls(channel_id=channel_id)
elif data['type'] == AutoModRuleActionType.block_message.value:
custom_message = data.get('metadata', {}).get('custom_message')
return cls(type=AutoModRuleActionType.block_message, custom_message=custom_message)
return cls(type=AutoModRuleActionType.block_member_interactions)
def to_dict(self) -> Dict[str, Any]:
ret = {'type': self.type.value, 'metadata': {}}
if self.type is AutoModRuleActionType.block_message and self.custom_message is not None:
ret['metadata'] = {'custom_message': self.custom_message}
elif self.type is AutoModRuleActionType.timeout:
ret['metadata'] = {'duration_seconds': int(self.duration.total_seconds())} # type: ignore # duration cannot be None here
elif self.type is AutoModRuleActionType.send_alert_message:
ret['metadata'] = {'channel_id': str(self.channel_id)}
return ret
class AutoModTrigger:
r"""Represents a trigger for an auto moderation rule.
The following table illustrates relevant attributes for each :class:`AutoModRuleTriggerType`:
+-----------------------------------------------+------------------------------------------------+
| Type | Attributes |
+===============================================+================================================+
| :attr:`AutoModRuleTriggerType.keyword` | :attr:`keyword_filter`, :attr:`regex_patterns`,|
| | :attr:`allow_list` |
+-----------------------------------------------+------------------------------------------------+
| :attr:`AutoModRuleTriggerType.spam` | |
+-----------------------------------------------+------------------------------------------------+
| :attr:`AutoModRuleTriggerType.keyword_preset` | :attr:`presets`\, :attr:`allow_list` |
+-----------------------------------------------+------------------------------------------------+
| :attr:`AutoModRuleTriggerType.mention_spam` | :attr:`mention_limit`, |
| | :attr:`mention_raid_protection` |
+-----------------------------------------------+------------------------------------------------+
| :attr:`AutoModRuleTriggerType.member_profile` | :attr:`keyword_filter`, :attr:`regex_patterns`,|
| | :attr:`allow_list` |
+-----------------------------------------------+------------------------------------------------+
.. versionadded:: 2.0
Attributes
-----------
type: :class:`AutoModRuleTriggerType`
The type of trigger.
keyword_filter: List[:class:`str`]
The list of strings that will trigger the filter.
Maximum of 1000. Keywords can only be up to 60 characters in length.
This could be combined with :attr:`regex_patterns`.
regex_patterns: List[:class:`str`]
The regex pattern that will trigger the filter. The syntax is based off of
`Rust's regex syntax <https://docs.rs/regex/latest/regex/#syntax>`_.
Maximum of 10. Regex strings can only be up to 260 characters in length.
This could be combined with :attr:`keyword_filter` and/or :attr:`allow_list`
.. versionadded:: 2.1
presets: :class:`AutoModPresets`
The presets used with the preset keyword filter.
allow_list: List[:class:`str`]
The list of words that are exempt from the commonly flagged words. Maximum of 100.
Keywords can only be up to 60 characters in length.
mention_limit: :class:`int`
The total number of user and role mentions a message can contain.
Has a maximum of 50.
mention_raid_protection: :class:`bool`
Whether mention raid protection is enabled or not.
.. versionadded:: 2.4
"""
__slots__ = (
'type',
'keyword_filter',
'presets',
'allow_list',
'mention_limit',
'regex_patterns',
'mention_raid_protection',
)
def __init__(
self,
*,
type: Optional[AutoModRuleTriggerType] = None,
keyword_filter: Optional[List[str]] = None,
presets: Optional[AutoModPresets] = None,
allow_list: Optional[List[str]] = None,
mention_limit: Optional[int] = None,
regex_patterns: Optional[List[str]] = None,
mention_raid_protection: Optional[bool] = None,
) -> None:
unique_args = (keyword_filter or regex_patterns, presets, mention_limit or mention_raid_protection)
if type is None and sum(arg is not None for arg in unique_args) > 1:
raise ValueError(
'Please pass only one of keyword_filter/regex_patterns, presets, or mention_limit/mention_raid_protection.'
)
if type is not None:
self.type = type
elif keyword_filter is not None or regex_patterns is not None:
self.type = AutoModRuleTriggerType.keyword
elif presets is not None:
self.type = AutoModRuleTriggerType.keyword_preset
elif mention_limit is not None or mention_raid_protection is not None:
self.type = AutoModRuleTriggerType.mention_spam
else:
raise ValueError(
'Please pass the trigger type explicitly if not using keyword_filter, regex_patterns, presets, mention_limit, or mention_raid_protection.'
)
self.keyword_filter: List[str] = keyword_filter if keyword_filter is not None else []
self.presets: AutoModPresets = presets if presets is not None else AutoModPresets()
self.allow_list: List[str] = allow_list if allow_list is not None else []
self.mention_limit: int = mention_limit if mention_limit is not None else 0
self.mention_raid_protection: bool = mention_raid_protection if mention_raid_protection is not None else False
self.regex_patterns: List[str] = regex_patterns if regex_patterns is not None else []
def __repr__(self) -> str:
data = self.to_metadata_dict()
if data:
joined = ' '.join(f'{k}={v!r}' for k, v in data.items())
return f'<AutoModTrigger type={self.type} {joined}>'
return f'<AutoModTrigger type={self.type}>'
@classmethod
def from_data(cls, type: int, data: Optional[AutoModerationTriggerMetadataPayload]) -> Self:
type_ = try_enum(AutoModRuleTriggerType, type)
if data is None:
return cls(type=type_)
elif type_ in (AutoModRuleTriggerType.keyword, AutoModRuleTriggerType.member_profile):
return cls(
type=type_,
keyword_filter=data.get('keyword_filter'),
regex_patterns=data.get('regex_patterns'),
allow_list=data.get('allow_list'),
)
elif type_ is AutoModRuleTriggerType.keyword_preset:
return cls(
type=type_, presets=AutoModPresets._from_value(data.get('presets', [])), allow_list=data.get('allow_list')
)
elif type_ is AutoModRuleTriggerType.mention_spam:
return cls(
type=type_,
mention_limit=data.get('mention_total_limit'),
mention_raid_protection=data.get('mention_raid_protection_enabled'),
)
else:
return cls(type=type_)
def to_metadata_dict(self) -> Optional[Dict[str, Any]]:
if self.type in (AutoModRuleTriggerType.keyword, AutoModRuleTriggerType.member_profile):
return {
'keyword_filter': self.keyword_filter,
'regex_patterns': self.regex_patterns,
'allow_list': self.allow_list,
}
elif self.type is AutoModRuleTriggerType.keyword_preset:
return {'presets': self.presets.to_array(), 'allow_list': self.allow_list}
elif self.type is AutoModRuleTriggerType.mention_spam:
return {
'mention_total_limit': self.mention_limit,
'mention_raid_protection_enabled': self.mention_raid_protection,
}
class AutoModRule:
"""Represents an auto moderation rule.
.. versionadded:: 2.0
Attributes
-----------
id: :class:`int`
The ID of the rule.
guild: :class:`Guild`
The guild the rule is for.
name: :class:`str`
The name of the rule.
creator_id: :class:`int`
The ID of the user that created the rule.
trigger: :class:`AutoModTrigger`
The rule's trigger.
enabled: :class:`bool`
Whether the rule is enabled.
exempt_role_ids: Set[:class:`int`]
The IDs of the roles that are exempt from the rule.
exempt_channel_ids: Set[:class:`int`]
The IDs of the channels that are exempt from the rule.
event_type: :class:`AutoModRuleEventType`
The type of event that will trigger the the rule.
"""
__slots__ = (
'_state',
'_cs_exempt_roles',
'_cs_exempt_channels',
'_cs_actions',
'id',
'guild',
'name',
'creator_id',
'event_type',
'trigger',
'enabled',
'exempt_role_ids',
'exempt_channel_ids',
'_actions',
)
def __init__(self, *, data: AutoModerationRulePayload, guild: Guild, state: ConnectionState) -> None:
self._state: ConnectionState = state
self.guild: Guild = guild
self.id: int = int(data['id'])
self.name: str = data['name']
self.creator_id = int(data['creator_id'])
self.event_type: AutoModRuleEventType = try_enum(AutoModRuleEventType, data['event_type'])
self.trigger: AutoModTrigger = AutoModTrigger.from_data(data['trigger_type'], data=data.get('trigger_metadata'))
self.enabled: bool = data['enabled']
self.exempt_role_ids: Set[int] = {int(role_id) for role_id in data['exempt_roles']}
self.exempt_channel_ids: Set[int] = {int(channel_id) for channel_id in data['exempt_channels']}
self._actions: List[AutoModerationActionPayload] = data['actions']
def __repr__(self) -> str:
return f'<AutoModRule id={self.id} name={self.name!r} guild={self.guild!r}>'
def to_dict(self) -> AutoModerationRulePayload:
ret: AutoModerationRulePayload = {
'id': str(self.id),
'guild_id': str(self.guild.id),
'name': self.name,
'creator_id': str(self.creator_id),
'event_type': self.event_type.value,
'trigger_type': self.trigger.type.value,
'trigger_metadata': self.trigger.to_metadata_dict(),
'actions': [action.to_dict() for action in self.actions],
'enabled': self.enabled,
'exempt_roles': [str(role_id) for role_id in self.exempt_role_ids],
'exempt_channels': [str(channel_id) for channel_id in self.exempt_channel_ids],
} # type: ignore # trigger types break the flow here.
return ret
@property
def creator(self) -> Optional[Member]:
"""Optional[:class:`Member`]: The member that created this rule."""
return self.guild.get_member(self.creator_id)
@cached_slot_property('_cs_exempt_roles')
def exempt_roles(self) -> List[Role]:
"""List[:class:`Role`]: The roles that are exempt from this rule."""
result = []
get_role = self.guild.get_role
for role_id in self.exempt_role_ids:
role = get_role(role_id)
if role is not None:
result.append(role)
return utils._unique(result)
@cached_slot_property('_cs_exempt_channels')
def exempt_channels(self) -> List[Union[GuildChannel, Thread]]:
"""List[Union[:class:`abc.GuildChannel`, :class:`Thread`]]: The channels that are exempt from this rule."""
it = filter(None, map(self.guild._resolve_channel, self.exempt_channel_ids))
return utils._unique(it)
@cached_slot_property('_cs_actions')
def actions(self) -> List[AutoModRuleAction]:
"""List[:class:`AutoModRuleAction`]: The actions that are taken when this rule is triggered."""
return [AutoModRuleAction.from_data(action) for action in self._actions]
def is_exempt(self, obj: Snowflake, /) -> bool:
"""Check if an object is exempt from the automod rule.
Parameters
-----------
obj: :class:`abc.Snowflake`
The role, channel, or thread to check.
Returns
--------
:class:`bool`
Whether the object is exempt from the automod rule.
"""
return obj.id in self.exempt_channel_ids or obj.id in self.exempt_role_ids
async def edit(
self,
*,
name: str = MISSING,
event_type: AutoModRuleEventType = MISSING,
actions: List[AutoModRuleAction] = MISSING,
trigger: AutoModTrigger = MISSING,
enabled: bool = MISSING,
exempt_roles: Sequence[Snowflake] = MISSING,
exempt_channels: Sequence[Snowflake] = MISSING,
reason: str = MISSING,
) -> Self:
"""|coro|
Edits this auto moderation rule.
You must have :attr:`Permissions.manage_guild` to edit rules.
Parameters
-----------
name: :class:`str`
The new name to change to.
event_type: :class:`AutoModRuleEventType`
The new event type to change to.
actions: List[:class:`AutoModRuleAction`]
The new rule actions to update.
trigger: :class:`AutoModTrigger`
The new trigger to update.
You can only change the trigger metadata, not the type.
enabled: :class:`bool`
Whether the rule should be enabled or not.
exempt_roles: Sequence[:class:`abc.Snowflake`]
The new roles to exempt from the rule.
exempt_channels: Sequence[:class:`abc.Snowflake`]
The new channels to exempt from the rule.
reason: :class:`str`
The reason for updating this rule. Shows up on the audit log.
Raises
-------
Forbidden
You do not have permission to edit this rule.
HTTPException
Editing the rule failed.
Returns
--------
:class:`AutoModRule`
The updated auto moderation rule.
"""
payload = {}
if actions is not MISSING:
payload['actions'] = [action.to_dict() for action in actions]
if name is not MISSING:
payload['name'] = name
if event_type is not MISSING:
payload['event_type'] = event_type.value
if trigger is not MISSING:
trigger_metadata = trigger.to_metadata_dict()
if trigger_metadata is not None:
payload['trigger_metadata'] = trigger_metadata
if enabled is not MISSING:
payload['enabled'] = enabled
if exempt_roles is not MISSING:
payload['exempt_roles'] = [x.id for x in exempt_roles]
if exempt_channels is not MISSING:
payload['exempt_channels'] = [x.id for x in exempt_channels]
data = await self._state.http.edit_auto_moderation_rule(
self.guild.id,
self.id,
reason=reason,
**payload,
)
return self.__class__(data=data, guild=self.guild, state=self._state)
async def delete(self, *, reason: str = MISSING) -> None:
"""|coro|
Deletes the auto moderation rule.
You must have :attr:`Permissions.manage_guild` to delete rules.
Parameters
-----------
reason: :class:`str`
The reason for deleting this rule. Shows up on the audit log.
Raises
-------
Forbidden
You do not have permissions to delete the rule.
HTTPException
Deleting the rule failed.
"""
await self._state.http.delete_auto_moderation_rule(self.guild.id, self.id, reason=reason)
class AutoModAction:
"""Represents an action that was taken as the result of a moderation rule.
.. versionadded:: 2.0
Attributes
-----------
action: :class:`AutoModRuleAction`
The action that was taken.
message_id: Optional[:class:`int`]
The message ID that triggered the action. This is only available if the
action is done on an edited message.
rule_id: :class:`int`
The ID of the rule that was triggered.
rule_trigger_type: :class:`AutoModRuleTriggerType`
The trigger type of the rule that was triggered.
guild_id: :class:`int`
The ID of the guild where the rule was triggered.
user_id: :class:`int`
The ID of the user that triggered the rule.
channel_id: :class:`int`
The ID of the channel where the rule was triggered.
alert_system_message_id: Optional[:class:`int`]
The ID of the system message that was sent to the predefined alert channel.
content: :class:`str`
The content of the message that triggered the rule.
Requires the :attr:`Intents.message_content` or it will always return an empty string.
matched_keyword: Optional[:class:`str`]
The matched keyword from the triggering message.
matched_content: Optional[:class:`str`]
The matched content from the triggering message.
Requires the :attr:`Intents.message_content` or it will always return ``None``.
"""
__slots__ = (
'_state',
'action',
'rule_id',
'rule_trigger_type',
'guild_id',
'user_id',
'channel_id',
'message_id',
'alert_system_message_id',
'content',
'matched_keyword',
'matched_content',
)
def __init__(self, *, data: AutoModerationActionExecutionPayload, state: ConnectionState) -> None:
self._state: ConnectionState = state
self.message_id: Optional[int] = utils._get_as_snowflake(data, 'message_id')
self.action: AutoModRuleAction = AutoModRuleAction.from_data(data['action'])
self.rule_id: int = int(data['rule_id'])
self.rule_trigger_type: AutoModRuleTriggerType = try_enum(AutoModRuleTriggerType, data['rule_trigger_type'])
self.guild_id: int = int(data['guild_id'])
self.channel_id: Optional[int] = utils._get_as_snowflake(data, 'channel_id')
self.user_id: int = int(data['user_id'])
self.alert_system_message_id: Optional[int] = utils._get_as_snowflake(data, 'alert_system_message_id')
self.content: str = data.get('content', '')
self.matched_keyword: Optional[str] = data['matched_keyword']
self.matched_content: Optional[str] = data.get('matched_content')
def __repr__(self) -> str:
return f'<AutoModRuleExecution rule_id={self.rule_id} action={self.action!r}>'
@property
def guild(self) -> Guild:
""":class:`Guild`: The guild this action was taken in."""
return self._state._get_or_create_unavailable_guild(self.guild_id)
@property
def channel(self) -> Optional[Union[GuildChannel, Thread]]:
"""Optional[Union[:class:`abc.GuildChannel`, :class:`Thread`]]: The channel this action was taken in."""
if self.channel_id:
return self.guild.get_channel_or_thread(self.channel_id)
return None
@property
def member(self) -> Optional[Member]:
"""Optional[:class:`Member`]: The member this action was taken against /who triggered this rule."""
return self.guild.get_member(self.user_id)
async def fetch_rule(self) -> AutoModRule:
"""|coro|
Fetch the rule whose action was taken.
You must have :attr:`Permissions.manage_guild` to do this.
Raises
-------
Forbidden
You do not have permissions to view the rule.
HTTPException
Fetching the rule failed.
Returns
--------
:class:`AutoModRule`
The rule that was executed.
"""
data = await self._state.http.get_auto_moderation_rule(self.guild.id, self.rule_id)
return AutoModRule(data=data, guild=self.guild, state=self._state)

View File

@@ -0,0 +1,105 @@
"""
The MIT License (MIT)
Copyright (c) 2015-present Rapptz
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
import time
import random
from typing import Callable, Generic, Literal, TypeVar, overload, Union
T = TypeVar('T', bool, Literal[True], Literal[False])
# fmt: off
__all__ = (
'ExponentialBackoff',
)
# fmt: on
class ExponentialBackoff(Generic[T]):
"""An implementation of the exponential backoff algorithm
Provides a convenient interface to implement an exponential backoff
for reconnecting or retrying transmissions in a distributed network.
Once instantiated, the delay method will return the next interval to
wait for when retrying a connection or transmission. The maximum
delay increases exponentially with each retry up to a maximum of
2^10 * base, and is reset if no more attempts are needed in a period
of 2^11 * base seconds.
Parameters
----------
base: :class:`int`
The base delay in seconds. The first retry-delay will be up to
this many seconds.
integral: :class:`bool`
Set to ``True`` if whole periods of base is desirable, otherwise any
number in between may be returned.
"""
def __init__(self, base: int = 1, *, integral: T = False):
self._base: int = base
self._exp: int = 0
self._max: int = 10
self._reset_time: int = base * 2**11
self._last_invocation: float = time.monotonic()
# Use our own random instance to avoid messing with global one
rand = random.Random()
rand.seed()
self._randfunc: Callable[..., Union[int, float]] = rand.randrange if integral else rand.uniform
@overload
def delay(self: ExponentialBackoff[Literal[False]]) -> float: ...
@overload
def delay(self: ExponentialBackoff[Literal[True]]) -> int: ...
@overload
def delay(self: ExponentialBackoff[bool]) -> Union[int, float]: ...
def delay(self) -> Union[int, float]:
"""Compute the next delay
Returns the next delay to wait according to the exponential
backoff algorithm. This is a value between 0 and base * 2^exp
where exponent starts off at 1 and is incremented at every
invocation of this method up to a maximum of 10.
If a period of more than base * 2^11 has passed since the last
retry, the exponent is reset to 1.
"""
invocation = time.monotonic()
interval = invocation - self._last_invocation
self._last_invocation = invocation
if interval > self._reset_time:
self._exp = 0
self._exp = min(self._exp + 1, self._max)
return self._randfunc(0, self._base * 2**self._exp)

View File

@@ -0,0 +1,28 @@
Copyright (c) 1994-2013 Xiph.Org Foundation and contributors
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
- Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
- Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
- Neither the name of the Xiph.Org Foundation nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION
OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,593 @@
"""
The MIT License (MIT)
Copyright (c) 2015-present Rapptz
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
import colorsys
import random
import re
from typing import TYPE_CHECKING, Optional, Tuple, Union
if TYPE_CHECKING:
from typing_extensions import Self
__all__ = (
'Colour',
'Color',
)
RGB_REGEX = re.compile(r'rgb\s*\((?P<r>[0-9.]+%?)\s*,\s*(?P<g>[0-9.]+%?)\s*,\s*(?P<b>[0-9.]+%?)\s*\)')
def parse_hex_number(argument: str) -> Colour:
arg = ''.join(i * 2 for i in argument) if len(argument) == 3 else argument
try:
value = int(arg, base=16)
if not (0 <= value <= 0xFFFFFF):
raise ValueError('hex number out of range for 24-bit colour')
except ValueError:
raise ValueError('invalid hex digit given') from None
else:
return Color(value=value)
def parse_rgb_number(number: str) -> int:
if number[-1] == '%':
value = float(number[:-1])
if not (0 <= value <= 100):
raise ValueError('rgb percentage can only be between 0 to 100')
return round(255 * (value / 100))
value = int(number)
if not (0 <= value <= 255):
raise ValueError('rgb number can only be between 0 to 255')
return value
def parse_rgb(argument: str, *, regex: re.Pattern[str] = RGB_REGEX) -> Colour:
match = regex.match(argument)
if match is None:
raise ValueError('invalid rgb syntax found')
red = parse_rgb_number(match.group('r'))
green = parse_rgb_number(match.group('g'))
blue = parse_rgb_number(match.group('b'))
return Color.from_rgb(red, green, blue)
class Colour:
"""Represents a Discord role colour. This class is similar
to a (red, green, blue) :class:`tuple`.
There is an alias for this called Color.
.. container:: operations
.. describe:: x == y
Checks if two colours are equal.
.. describe:: x != y
Checks if two colours are not equal.
.. describe:: hash(x)
Return the colour's hash.
.. describe:: str(x)
Returns the hex format for the colour.
.. describe:: int(x)
Returns the raw colour value.
.. note::
The colour values in the classmethods are mostly provided as-is and can change between
versions should the Discord client's representation of that colour also change.
Attributes
------------
value: :class:`int`
The raw integer colour value.
"""
__slots__ = ('value',)
def __init__(self, value: int):
if not isinstance(value, int):
raise TypeError(f'Expected int parameter, received {value.__class__.__name__} instead.')
self.value: int = value
def _get_byte(self, byte: int) -> int:
return (self.value >> (8 * byte)) & 0xFF
def __eq__(self, other: object) -> bool:
return isinstance(other, Colour) and self.value == other.value
def __ne__(self, other: object) -> bool:
return not self.__eq__(other)
def __str__(self) -> str:
return f'#{self.value:0>6x}'
def __int__(self) -> int:
return self.value
def __repr__(self) -> str:
return f'<Colour value={self.value}>'
def __hash__(self) -> int:
return hash(self.value)
@property
def r(self) -> int:
""":class:`int`: Returns the red component of the colour."""
return self._get_byte(2)
@property
def g(self) -> int:
""":class:`int`: Returns the green component of the colour."""
return self._get_byte(1)
@property
def b(self) -> int:
""":class:`int`: Returns the blue component of the colour."""
return self._get_byte(0)
def to_rgb(self) -> Tuple[int, int, int]:
"""Tuple[:class:`int`, :class:`int`, :class:`int`]: Returns an (r, g, b) tuple representing the colour."""
return (self.r, self.g, self.b)
@classmethod
def from_rgb(cls, r: int, g: int, b: int) -> Self:
"""Constructs a :class:`Colour` from an RGB tuple."""
return cls((r << 16) + (g << 8) + b)
@classmethod
def from_hsv(cls, h: float, s: float, v: float) -> Self:
"""Constructs a :class:`Colour` from an HSV tuple."""
rgb = colorsys.hsv_to_rgb(h, s, v)
return cls.from_rgb(*(int(x * 255) for x in rgb))
@classmethod
def from_str(cls, value: str) -> Colour:
"""Constructs a :class:`Colour` from a string.
The following formats are accepted:
- ``0x<hex>``
- ``#<hex>``
- ``0x#<hex>``
- ``rgb(<number>, <number>, <number>)``
Like CSS, ``<number>`` can be either 0-255 or 0-100% and ``<hex>`` can be
either a 6 digit hex number or a 3 digit hex shortcut (e.g. #FFF).
.. versionadded:: 2.0
Raises
-------
ValueError
The string could not be converted into a colour.
"""
if not value:
raise ValueError('unknown colour format given')
if value[0] == '#':
return parse_hex_number(value[1:])
if value[0:2] == '0x':
rest = value[2:]
# Legacy backwards compatible syntax
if rest.startswith('#'):
return parse_hex_number(rest[1:])
return parse_hex_number(rest)
arg = value.lower()
if arg[0:3] == 'rgb':
return parse_rgb(arg)
raise ValueError('unknown colour format given')
@classmethod
def default(cls) -> Self:
"""A factory method that returns a :class:`Colour` with a value of ``0``.
.. colour:: #000000
"""
return cls(0)
@classmethod
def random(cls, *, seed: Optional[Union[int, str, float, bytes, bytearray]] = None) -> Self:
"""A factory method that returns a :class:`Colour` with a random hue.
.. note::
The random algorithm works by choosing a colour with a random hue but
with maxed out saturation and value.
.. versionadded:: 1.6
Parameters
------------
seed: Optional[Union[:class:`int`, :class:`str`, :class:`float`, :class:`bytes`, :class:`bytearray`]]
The seed to initialize the RNG with. If ``None`` is passed the default RNG is used.
.. versionadded:: 1.7
"""
rand = random if seed is None else random.Random(seed)
return cls.from_hsv(rand.random(), 1, 1)
@classmethod
def teal(cls) -> Self:
"""A factory method that returns a :class:`Colour` with a value of ``0x1ABC9C``.
.. colour:: #1ABC9C
"""
return cls(0x1ABC9C)
@classmethod
def dark_teal(cls) -> Self:
"""A factory method that returns a :class:`Colour` with a value of ``0x11806A``.
.. colour:: #11806A
"""
return cls(0x11806A)
@classmethod
def brand_green(cls) -> Self:
"""A factory method that returns a :class:`Colour` with a value of ``0x57F287``.
.. colour:: #57F287
.. versionadded:: 2.0
"""
return cls(0x57F287)
@classmethod
def green(cls) -> Self:
"""A factory method that returns a :class:`Colour` with a value of ``0x2ECC71``.
.. colour:: #2ECC71
"""
return cls(0x2ECC71)
@classmethod
def dark_green(cls) -> Self:
"""A factory method that returns a :class:`Colour` with a value of ``0x1F8B4C``.
.. colour:: #1F8B4C
"""
return cls(0x1F8B4C)
@classmethod
def blue(cls) -> Self:
"""A factory method that returns a :class:`Colour` with a value of ``0x3498DB``.
.. colour:: #3498DB
"""
return cls(0x3498DB)
@classmethod
def dark_blue(cls) -> Self:
"""A factory method that returns a :class:`Colour` with a value of ``0x206694``.
.. colour:: #206694
"""
return cls(0x206694)
@classmethod
def purple(cls) -> Self:
"""A factory method that returns a :class:`Colour` with a value of ``0x9B59B6``.
.. colour:: #9B59B6
"""
return cls(0x9B59B6)
@classmethod
def dark_purple(cls) -> Self:
"""A factory method that returns a :class:`Colour` with a value of ``0x71368A``.
.. colour:: #71368A
"""
return cls(0x71368A)
@classmethod
def magenta(cls) -> Self:
"""A factory method that returns a :class:`Colour` with a value of ``0xE91E63``.
.. colour:: #E91E63
"""
return cls(0xE91E63)
@classmethod
def dark_magenta(cls) -> Self:
"""A factory method that returns a :class:`Colour` with a value of ``0xAD1457``.
.. colour:: #AD1457
"""
return cls(0xAD1457)
@classmethod
def gold(cls) -> Self:
"""A factory method that returns a :class:`Colour` with a value of ``0xF1C40F``.
.. colour:: #F1C40F
"""
return cls(0xF1C40F)
@classmethod
def dark_gold(cls) -> Self:
"""A factory method that returns a :class:`Colour` with a value of ``0xC27C0E``.
.. colour:: #C27C0E
"""
return cls(0xC27C0E)
@classmethod
def orange(cls) -> Self:
"""A factory method that returns a :class:`Colour` with a value of ``0xE67E22``.
.. colour:: #E67E22
"""
return cls(0xE67E22)
@classmethod
def dark_orange(cls) -> Self:
"""A factory method that returns a :class:`Colour` with a value of ``0xA84300``.
.. colour:: #A84300
"""
return cls(0xA84300)
@classmethod
def brand_red(cls) -> Self:
"""A factory method that returns a :class:`Colour` with a value of ``0xED4245``.
.. colour:: #ED4245
.. versionadded:: 2.0
"""
return cls(0xED4245)
@classmethod
def red(cls) -> Self:
"""A factory method that returns a :class:`Colour` with a value of ``0xE74C3C``.
.. colour:: #E74C3C
"""
return cls(0xE74C3C)
@classmethod
def dark_red(cls) -> Self:
"""A factory method that returns a :class:`Colour` with a value of ``0x992D22``.
.. colour:: #992D22
"""
return cls(0x992D22)
@classmethod
def lighter_grey(cls) -> Self:
"""A factory method that returns a :class:`Colour` with a value of ``0x95A5A6``.
.. colour:: #95A5A6
"""
return cls(0x95A5A6)
lighter_gray = lighter_grey
@classmethod
def dark_grey(cls) -> Self:
"""A factory method that returns a :class:`Colour` with a value of ``0x607d8b``.
.. colour:: #607d8b
"""
return cls(0x607D8B)
dark_gray = dark_grey
@classmethod
def light_grey(cls) -> Self:
"""A factory method that returns a :class:`Colour` with a value of ``0x979C9F``.
.. colour:: #979C9F
"""
return cls(0x979C9F)
light_gray = light_grey
@classmethod
def darker_grey(cls) -> Self:
"""A factory method that returns a :class:`Colour` with a value of ``0x546E7A``.
.. colour:: #546E7A
"""
return cls(0x546E7A)
darker_gray = darker_grey
@classmethod
def og_blurple(cls) -> Self:
"""A factory method that returns a :class:`Colour` with a value of ``0x7289DA``.
.. colour:: #7289DA
"""
return cls(0x7289DA)
@classmethod
def blurple(cls) -> Self:
"""A factory method that returns a :class:`Colour` with a value of ``0x5865F2``.
.. colour:: #5865F2
"""
return cls(0x5865F2)
@classmethod
def greyple(cls) -> Self:
"""A factory method that returns a :class:`Colour` with a value of ``0x99AAB5``.
.. colour:: #99AAB5
"""
return cls(0x99AAB5)
@classmethod
def ash_theme(cls) -> Self:
"""A factory method that returns a :class:`Colour` with a value of ``0x2E2E34``.
This will appear transparent on Discord's ash theme.
.. colour:: #2E2E34
.. versionadded:: 2.6
"""
return cls(0x2E2E34)
@classmethod
def dark_theme(cls) -> Self:
"""A factory method that returns a :class:`Colour` with a value of ``0x1A1A1E``.
This will appear transparent on Discord's dark theme.
.. colour:: #1A1A1E
.. versionadded:: 1.5
.. versionchanged:: 2.2
Updated colour from previous ``0x36393F`` to reflect discord theme changes.
.. versionchanged:: 2.6
Updated colour from previous ``0x313338`` to reflect discord theme changes.
"""
return cls(0x1A1A1E)
@classmethod
def onyx_theme(cls) -> Self:
"""A factory method that returns a :class:`Colour` with a value of ``0x070709``.
This will appear transparent on Discord's onyx theme.
.. colour:: #070709
.. versionadded:: 2.6
"""
return cls(0x070709)
@classmethod
def light_theme(cls) -> Self:
"""A factory method that returns a :class:`Colour` with a value of ``0xFBFBFB``.
This will appear transparent on Discord's light theme.
.. colour:: #FBFBFB
.. versionadded:: 2.6
"""
return cls(0xFBFBFB)
@classmethod
def fuchsia(cls) -> Self:
"""A factory method that returns a :class:`Colour` with a value of ``0xEB459E``.
.. colour:: #EB459E
.. versionadded:: 2.0
"""
return cls(0xEB459E)
@classmethod
def yellow(cls) -> Self:
"""A factory method that returns a :class:`Colour` with a value of ``0xFEE75C``.
.. colour:: #FEE75C
.. versionadded:: 2.0
"""
return cls(0xFEE75C)
@classmethod
def ash_embed(cls) -> Self:
"""A factory method that returns a :class:`Colour` with a value of ``0x37373E``.
.. colour:: #37373E
.. versionadded:: 2.6
"""
return cls(0x37373E)
@classmethod
def dark_embed(cls) -> Self:
"""A factory method that returns a :class:`Colour` with a value of ``0x242429``.
.. colour:: #242429
.. versionadded:: 2.2
.. versionchanged:: 2.6
Updated colour from previous ``0x2B2D31`` to reflect discord theme changes.
"""
return cls(0x242429)
@classmethod
def onyx_embed(cls) -> Self:
"""A factory method that returns a :class:`Colour` with a value of ``0x131416``.
.. colour:: #131416
.. versionadded:: 2.6
"""
return cls(0x131416)
@classmethod
def light_embed(cls) -> Self:
"""A factory method that returns a :class:`Colour` with a value of ``0xFFFFFF``.
.. colour:: #EEEFF1
.. versionadded:: 2.2
.. versionchanged:: 2.6
Updated colour from previous ``0xEEEFF1`` to reflect discord theme changes.
"""
return cls(0xFFFFFF)
@classmethod
def pink(cls) -> Self:
"""A factory method that returns a :class:`Colour` with a value of ``0xEB459F``.
.. colour:: #EB459F
.. versionadded:: 2.3
"""
return cls(0xEB459F)
Color = Colour

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,92 @@
"""
The MIT License (MIT)
Copyright (c) 2015-present Rapptz
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""
from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING, Generator, Optional, Type, TypeVar
if TYPE_CHECKING:
from .abc import Messageable, MessageableChannel
from types import TracebackType
BE = TypeVar('BE', bound=BaseException)
# fmt: off
__all__ = (
'Typing',
)
# fmt: on
def _typing_done_callback(fut: asyncio.Future) -> None:
# just retrieve any exception and call it a day
try:
fut.exception()
except (asyncio.CancelledError, Exception):
pass
class Typing:
def __init__(self, messageable: Messageable) -> None:
self.loop: asyncio.AbstractEventLoop = messageable._state.loop
self.messageable: Messageable = messageable
self.channel: Optional[MessageableChannel] = None
async def _get_channel(self) -> MessageableChannel:
if self.channel:
return self.channel
self.channel = channel = await self.messageable._get_channel()
return channel
async def wrapped_typer(self) -> None:
channel = await self._get_channel()
await channel._state.http.send_typing(channel.id)
def __await__(self) -> Generator[None, None, None]:
return self.wrapped_typer().__await__()
async def do_typing(self) -> None:
channel = await self._get_channel()
typing = channel._state.http.send_typing
while True:
await asyncio.sleep(5)
await typing(channel.id)
async def __aenter__(self) -> None:
channel = await self._get_channel()
await channel._state.http.send_typing(channel.id)
self.task: asyncio.Task[None] = self.loop.create_task(self.do_typing())
self.task.add_done_callback(_typing_done_callback)
async def __aexit__(
self,
exc_type: Optional[Type[BE]],
exc: Optional[BE],
traceback: Optional[TracebackType],
) -> None:
self.task.cancel()

Some files were not shown because too many files have changed in this diff Show More