diff --git a/examples/app_commands/basic_command.py b/examples/app_commands/basic_command.py index e91f4ab..af8bece 100644 --- a/examples/app_commands/basic_command.py +++ b/examples/app_commands/basic_command.py @@ -8,14 +8,14 @@ """ # Import client -from disspy import Client +from pytecord import Client # Create a client # note: You should replace 'token' to your token client = Client(token='token') # Import application commands module -from disspy import app +from pytecord import app # Create a command! # Docstring is description of command (visible in discord) diff --git a/examples/app_commands/context_menus.py b/examples/app_commands/context_menus.py index 221a340..f1d60fc 100644 --- a/examples/app_commands/context_menus.py +++ b/examples/app_commands/context_menus.py @@ -8,14 +8,14 @@ """ # For start - create a client -from disspy import Client +from pytecord import Client client = Client(token='token') # import commands module: -from disspy import app +from pytecord import app # Import messages: -from disspy import Message +from pytecord import Message # note: context menus is divided for 2 types: User and Message @@ -27,7 +27,7 @@ async def message_context_menu(ctx: app.Context, message: Message): await ctx.send_message('Content:', message.content, '\n', 'Id:', message.id, ephemeral=True) # Import users: -from disspy import User +from pytecord import User # This is example user context menu # 'user: User' is showing that command is user context menu diff --git a/examples/app_commands/modals.py b/examples/app_commands/modals.py index fdf1dcc..56ece23 100644 --- a/examples/app_commands/modals.py +++ b/examples/app_commands/modals.py @@ -7,14 +7,14 @@ """ # Create a client -from disspy import Client +from pytecord import Client client = Client(token='token') # Create a command -from disspy import app +from pytecord import app # note: also you must import ui module -from disspy import ui +from pytecord import ui @client.command() async def modals(ctx: app.Context): diff --git a/examples/events/ready.py b/examples/events/ready.py index 0813944..4e96e88 100644 --- a/examples/events/ready.py +++ b/examples/events/ready.py @@ -7,7 +7,7 @@ """ # Create a client -from disspy import Client +from pytecord import Client client = Client(token='token') # Replace with your token # For using events you can use `event` decorator diff --git a/examples/get_started.py b/examples/get_started.py index 4a5bdac..4409a92 100644 --- a/examples/get_started.py +++ b/examples/get_started.py @@ -8,10 +8,10 @@ """ # For get started import library -import disspy +import pytecord # Also you can import any things using `from` -from disspy import Client +from pytecord import Client # Let's create client! # 1 step: Copy token from Discord Developer Portal diff --git a/import_test.py b/import_test.py index f45d6ff..54e2765 100644 --- a/import_test.py +++ b/import_test.py @@ -2,6 +2,6 @@ Test import """ -import disspy +import pytecord print("Success: %s" % "import disspy") diff --git a/pytecord/__init__.py b/pytecord/__init__.py new file mode 100644 index 0000000..d0e7b8f --- /dev/null +++ b/pytecord/__init__.py @@ -0,0 +1,46 @@ +''' +Pytecord is a libary for simple creating bot clients in discord API written in Python + +Example client: +``` +from pytecord import Client + +client = Client(token='your_token') + +@client.event +async def ready(): + print("Hello! I'm ready!") + +client() +``` + +This bot will print "Hello! I'm ready!" string in console when it become ready + +More examples you can find in `examples` directory on this link: + + +### Links + +GitHub repo: https://github.com/pixeldeee/pytecord + +More examples: https://github.com/pixeldeee/pytecord/tree/master/examples + +PyPi: https://pypi.org/project/pytecord + +Docs: https://disspy.readthedocs.io/en/latest +''' + +from pytecord.app import * +from pytecord.channel import * +from pytecord.client import * +from pytecord.profiles import * +from pytecord.role import * +from pytecord.ui import * +from pytecord.files import * + +# Info +__version__: str = '1.0-alpha-1' +__lib_name__: str = 'pytecord' +__lib_description__: str = ( + 'Pytecord is a library for simple creating bot clients in discord API written in Python' +) diff --git a/pytecord/annotations.py b/pytecord/annotations.py new file mode 100644 index 0000000..f6535ad --- /dev/null +++ b/pytecord/annotations.py @@ -0,0 +1,43 @@ +from typing import Protocol, Generic, TypeVar, TypeAlias + +__all__ = ( + 'Strable', +) + +T = TypeVar('T') +ST = TypeVar('ST', bound=str) + +class Strable(Protocol): + ''' + (protocol) Strable + ''' + def __str__(self) -> str: + ... + +class Subclass(Generic[T]): + ''' + Only subclass of given class + + Example using: + ``` + Subclass[Class] + ``` + ''' + ... # pylint: disable=unnecessary-ellipsis + +class Startswith(str, Generic[ST]): + ''' + Indicates that this string startswith other string + + Using: + ``` + Startswith['I like disspy'] # F. e., 'I like disspy very much!' + ``` + ''' + ... # pylint: disable=unnecessary-ellipsis + +# Type aliases +Snowflake: TypeAlias = int # f. e., 1234567890987654321 +Filename: TypeAlias = str +Url: TypeAlias = Startswith['http'] +ProxyUrl: TypeAlias = Url diff --git a/pytecord/channel.py b/pytecord/channel.py new file mode 100644 index 0000000..03538be --- /dev/null +++ b/pytecord/channel.py @@ -0,0 +1,203 @@ +from asyncio import get_event_loop + +from pytecord import utils +from pytecord.payloads import MessagePayload +from pytecord.route import Route + +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from pytecord.annotations import Strable + +__all__ = ( + 'Message', + 'RawMessage', + 'Channel', +) + +class Message: + ''' + Channel message object + + ### Magic operations + --- + + `str()` -> Message content + + `int()` -> Message id + + `in` -> Check what message contains in channel + + `==` -> This message is equal with other message + + `!=` -> This message is not equals with other message + + `<` -> Message life time (how long the message has been sent) less that other message life time + (ID1 > ID2) + + `>` -> Message life time more that other message life time (ID1 < ID2) + + `<=` -> Message life time less or equals that other message life time (ID1 >= ID2) + + `>=` -> Message life time more or equals that other message life time (ID1 <= ID2) + ``` + # message1.id = 1; message2.id = 2; message = message1 + str(message) # message.content + int(message) # message.id + + if message in channel: # if message.channel.id == channel.id + print('This message in this channel!') + + print('Equals!' if message1 == message2 else 'Not equals!') + print('Not equals!' if message1 != message2 else 'Equals!') + + print(message1 < message2) # False + print(message1 > message2) # True + print(message1 <= message2) # False + print(message1 >= message2) # True + ``` + ''' + def __init__(self, session, **data: MessagePayload) -> None: + self._session = session + + _ = data.get + self.id: int = int(_('id')) + self.channel_id: int = _('channel_id') + self.author = _('author', None) # todo: Add support for users + self.content = _('content', None) + self.timestamp: str = _('timestamp') + self.edited_timestamp: str | None = _('edited_timestamp', None) + self.tts: bool = _('tts') + self.mention_everyone: bool = _('mention_everyone') + self.mentions: list[dict] = _('mentions', []) # todo: Add support for users + self.mention_roles: list[dict] = _('mention_roles', []) # todo: Add support for roles + self.mention_channels: list[dict] = _('mention_channels', []) # todo: Add assign mention channels + self.attachments: list[dict] = _('attachments', []) # todo: Add support for attachments + self.embeds: list[dict] = _('embeds', []) # todo: Add support for embeds + self.reactions: dict | None = _('reactions', None) # todo: Add support for reactions + self.pinned: bool = _('pinned') + self.webhook_id: int | None = _('webhook_id', None) + self.type: int = _('type') + self.application_id: int | None = _('application_id', None) + self.message_reference: data | None = _('message_reference', None) # todo: Add support for message reference + self.flags: int | None = _('flags', None) + self.referenced_message: dict | None = _('referenced_message', None) # todo: Add assign referenced message + self.interaction: dict | None = _('interaction', None) # todo: Add support for interactions + self.thread: dict | None = _('thread', None) # todo: Add assign thread + self.components: list[dict] = _('components', []) + self.stickers: list[dict] = _('stickers', []) # todo: Add support for stickers + self.position: int | None = _('position', None) + + @property + def channel(self) -> 'Channel': + channel_json, _ = Route( + '/channels/%s', self.channel_id, + method='GET', + token=utils.get_token_from_auth(self._session.headers) + ).request() + return Channel(self._session, **channel_json) + + def __str__(self) -> str: + return self.content + def __int__(self) -> int: + return self.id + def __eq__(self, __o: 'Message') -> bool: # == + return self.id == __o.id + def __ne__(self, __o: 'Message') -> bool: # != + return self.id != __o.id + def __lt__(self, other: 'Message') -> bool: # < + return self.id > other.id + def __gt__(self, other: 'Message') -> bool: # > + return self.id < other.id + def __le__(self, other: 'Message'): # <= + return self.id >= other.id + def __ge__(self, other: 'Message'): # >= + return self.id <= other.id + + async def reply(self, *strings: 'list[Strable]', sep: str = ' '): + ''' + Reply to a message + ''' + payload = utils.message_payload(*strings, sep=sep) + payload['message_reference'] = { + 'message_id': self.id + } + + route = Route( + '/channels/%s/messages', self.channel_id, + method='POST', + payload=payload + ) + j, _ = await route.async_request(self._session, get_event_loop()) + return Message(self._session, **j) + + +class RawMessage: + def __init__(self, session, **data) -> None: + self._session = session + + self.id: int = data.get('id', None) + self.channel_id: int = data.get('channel_id', None) + self.guild_id: int | None = data.get('guild_id', None) + + +class Channel: + ''' + Channel object. + + ### Magic operations + --- + + `str()` -> Name of channel + + `int()` -> Channel id + + `in` -> Check what message contains in channel + + `[key]` -> Fetch the message + ``` + str(channel) + int(channel) + + if message in channel: + print('This message in this channel!') + + fetched_message = channel[1076055795042615298] + ``` + ''' + def __init__(self, session, **data) -> None: + self._session = session + + _ = data.get + self.id: int = int(_('id')) + self.name: str = _('name') + + def fetch(self, message_to_fetch_id: int) -> 'Message': + data, _ = Route( + '/channels/%s/messages/%s', self.id, str(message_to_fetch_id), + method='GET', + token=utils.get_token_from_auth(self._session.headers) + ).request() + return Message(self._session, **data) + + def __str__(self) -> str: + return self.name + + def __int__(self) -> int: + return self.id + + def __contains__(self, value: 'Message') -> bool: + return self.id == value.channel_id + + def __getitem__(self, key: int) -> 'Message': + return self.fetch(key) + + async def send(self, *strings: 'list[Strable]', sep: str = ' ', tts: bool = False) -> 'Message | None': + route = Route( + '/channels/%s/messages', self.id, + method='POST', + payload=utils.message_payload(*strings, sep=sep, tts=tts) + ) + j, _ = await route.async_request(self._session, get_event_loop()) + return Message(self._session, **j) diff --git a/pytecord/client.py b/pytecord/client.py new file mode 100644 index 0000000..6d6fb2e --- /dev/null +++ b/pytecord/client.py @@ -0,0 +1,228 @@ +from asyncio import run as async_run +from inspect import _empty, getdoc, signature +from sys import exit as sys_exit +from typing import Any, Callable, Coroutine, Self, TypeVar, TypeAlias + +from regex import fullmatch + +from pytecord.app import AppClient, Command, ContextMenu +from pytecord.channel import Channel, Message +from pytecord.connection import Connection +from pytecord.enums import ApplicationCommandOptionType, ApplicationCommandType +from pytecord.listener import Listener +from pytecord.profiles import Member, User + +from pytecord.role import Role +from pytecord.files import Attachment + +SLASH_COMMAND_VALID_REGEX = r'^[-_\p{L}\p{N}\p{sc=Deva}\p{sc=Thai}]{1,32}$' + +CT = TypeVar( 'CT', bound=Callable[..., Coroutine[Any, Any, Any]] ) + +__all__ = ( + 'Mentionable', + 'Client', +) + +Mentionable: TypeAlias = Member | Role + +class _flags: + default = 16 + messages = 55824 + reactions = 9232 + +class Client: + ''' + Discord client. + + ### Magic operations + --- + + `+=` -> Add the event to the client + + `-=` -> Remove the event from the client + + `call` -> Run the bot + + `repr()` -> Get client data (for example, commands) + + ``` + async def ready(): ... + client += ready + + client -= ready # or 'ready' + + client() # equals with client.run() + + print(client) # Prints data about client + ``` + ''' + def _resolve_options(self, **options): + self.debug = options.get('debug', False) + def _validate_slash_command(self, name: str): + return bool(fullmatch(SLASH_COMMAND_VALID_REGEX, name)) + def _get_callable(self, func): + _c = func + try: + func.__name__ + except AttributeError: + _c = func[1] + return _c + + def __init__(self, *, token: str, **options): + self.debug = None + + self.token = token + self._conn = Connection(token=token, **options) + self._listener = Listener() + self._app = AppClient() + + self._intents = 0 + + self._resolve_options(**options) + + def run(self, **options): + try: + async_run(self._conn.run(self._listener, self._app, intents=self._intents, **options)) + except KeyboardInterrupt: + sys_exit(1) + + def __call__(self, **options: dict[str, Any]) -> None: + self.run(**options) + + def __repr__(self) -> str: + events = [a for a, b in self._listener.events.items() if b is not None] + commands = [i['name'] for i in self._app.commands] + + return f'Client(debug={self.debug}, events={events}, commands={commands})' + + def _get_options(self, option_tuples: list[tuple[str, tuple[type, Any]]]) -> list[dict]: + option_jsons = [] + option_types = { + 'SUB_COMMAND': ApplicationCommandOptionType.sub_command, + 'SUB_COMMAND_GROUP': ApplicationCommandOptionType.sub_command_group, + str: ApplicationCommandOptionType.string, + int: ApplicationCommandOptionType.integer, + bool: ApplicationCommandOptionType.boolean, + Member: ApplicationCommandOptionType.user, + Channel: ApplicationCommandOptionType.channel, + Role: ApplicationCommandOptionType.role, + Mentionable: ApplicationCommandOptionType.mentionable, + float: ApplicationCommandOptionType.number, + Attachment: ApplicationCommandOptionType.attachment, + } + for n, (t, d) in option_tuples: # pylint: disable=invalid-name + option_jsons.append({ + 'name': n, + 'type': option_types[t], + 'required': d == _empty, + }) + return option_jsons + + def command(self, __f: Callable[..., Coroutine[Any, Any, Any]]) -> Command: + """ + Create an app command + + ``` + @client.command + @app.describe( + first='First argument' + ) + async def test(ctx: Context, first: str): + await ctx.send_message(first) + ``` + + Returns: + Command: Created command + """ + callable = self._get_callable(__f) + + command_json = { + 'type': ApplicationCommandType.chat_input, + 'name': callable.__name__, + } + + description = x.splitlines()[0] if (x := getdoc(callable)) else None # pylint: disable=invalid-name + + params = dict(signature(callable).parameters) + option_tuples = [(k, (v.annotation, v.default)) for k, v in list(params.items())[1:]] + option_jsons = self._get_options(x) if (x := option_tuples) else [] # pylint: disable=invalid-name + + if option_jsons: + for i in option_jsons: + i['description'] = 'No description' + + command_json.update( + name=callable.__name__, + description=x if (x := description) else 'No description', # pylint: disable=invalid-name + options=option_jsons, + ) + + if isinstance(__f, tuple): + json, callable, string = __f + match string: + case 'describe': + if command_json.get('options'): + for option in command_json.get('options'): + for name, description in json.items(): + if option['name'] == name: + option['description'] = description + case _: + command_json.update(**json) + + if not self._validate_slash_command(command_json['name']): + raise ValueError( + f'All slash command names must followed {SLASH_COMMAND_VALID_REGEX} regex' + ) + + command = Command(command_json) + self._app.add_command(command, callable) + return command + + def _get_menu_type(self, func: Callable[..., Coroutine[Any, Any, Any]]): + params = dict(signature(func).parameters) + param_type = params[list(params.keys())[1]].annotation + types = { + User: ApplicationCommandType.user, + Message: ApplicationCommandType.message, + } + return types[param_type] + + def context_menu(self) -> Callable[..., ContextMenu]: + def wrapper(func: Callable[..., Coroutine[Any, Any, Any]]) -> ContextMenu: + menu_json = dict( + name=func.__name__, + type=self._get_menu_type(func) + ) + menu = ContextMenu(menu_json) + self._app.add_command(menu, func) + return menu + return wrapper + + def add_event(self, callback: CT) -> CT: + match callback.__name__: + case 'message' | 'message_delete': + self._intents += _flags.messages if self._intents & _flags.messages == 0 else 0 + + self._listener.add_event(callback.__name__, callback) + return callback + + def event(self, func: CT) -> CT: + self.add_event(func) + return func + + def __iadd__(self, other: Callable[..., Coroutine[Any, Any, Any]]) -> Self: + self.add_event(other) + return self + + def remove_event(self, callback_or_name: Callable[..., Coroutine[Any, Any, Any]] | str) -> None: + name = callback_or_name if isinstance(callback_or_name, str) else callback_or_name.__name__ + + match name: + case 'message' | 'message_delete': + self._intents -= self._intents & _flags.messages + self._listener.remove_event(name) + + def __isub__(self, other: Callable[..., Coroutine[Any, Any, Any]] | str) -> Self: + self.remove_event(other) + return self diff --git a/pytecord/connection.py b/pytecord/connection.py new file mode 100644 index 0000000..a42c08e --- /dev/null +++ b/pytecord/connection.py @@ -0,0 +1,20 @@ +from aiohttp import ClientSession + +from pytecord.hook import Hook + + +class Connection: + def __init__(self, *, token: str, **options) -> None: + self._run_token = token + self._hook = Hook(token=token, **options) + self._headers = { + "Authorization": f"Bot {token}", + "content-type": "application/json", + } + self._listener = None + + async def run(self, listener, app_client, **options): + self._listener = listener + + async with ClientSession(headers=self._headers) as session: + await self._hook.run(session, self._listener, app_client, **options) diff --git a/pytecord/enums.py b/pytecord/enums.py new file mode 100644 index 0000000..7a36619 --- /dev/null +++ b/pytecord/enums.py @@ -0,0 +1,250 @@ +class InteractionType: # pylint: disable=too-few-public-methods + ping = 1 + application_command = 2 + message_component = 3 + application_command_autocomplete = 4 + modal_submit = 5 + +class InteractionCallbackType: # pylint: disable=too-few-public-methods + pong = 1 + channel_message_with_source = 4 + deferred_channel_message_with_source = 5 + deferred_update_message = 6 + update_message = 7 + application_command_autocomplete_result = 8 + modal = 9 + +class ComponentType: # pylint: disable=too-few-public-methods + action_row = 1 + button = 2 + string_select = 3 + text_input = 4 + user_select = 5 + role_select = 6 + mentionable_select = 7 + channel_select = 8 + +class ApplicationCommandType: # pylint: disable=too-few-public-methods + chat_input = 1 + user = 2 + message = 3 + +class ApplicationCommandOptionType: # pylint: disable=too-few-public-methods + sub_command = 1 + sub_command_group = 2 + string = 3 + integer = 4 + boolean = 5 + user = 6 + channel = 7 + role = 8 + mentionable = 9 + number = 10 + attachment = 11 + +class ChannelType: # pylint: disable=too-few-public-methods + guild_text = 0 + dm = 1 + guild_voice = 2 + group_dm = 3 + guild_category = 4 + guild_announcement = 5 + announcement_thread = 10 + public_thread = 11 + private_thread = 12 + guild_stage_voice = 13 + guild_directory = 14 + guild_forum = 15 + +class MessageType: # pylint: disable=too-few-public-methods + default = 0 + recipient_add = 1 + recipient_remove = 2 + call = 3 + channel_name_change = 4 + channel_icon_change = 5 + channel_pinned_message = 6 + user_join = 7 + guild_boost = 8 + guild_boost_tier_1 = 9 + guild_boost_tier_2 = 10 + guild_boost_tier_3 = 11 + channel_follow_add = 12 + guild_discovery_disqualified = 14 + guild_discovery_requalified = 15 + guild_discovery_grace_period_initial_warning = 16 + guild_discovery_grace_period_final_warning = 17 + thread_created = 18 + reply = 19 + chat_input_command = 20 + thread_starter_message = 21 + guild_invite_reminder = 22 + context_menu_command = 23 + auto_moderation_action = 24 + role_subscription_purchase = 25 + interaction_premium_upsell = 26 + guild_application_premium_subscription = 32 + +class GatewayOpcode: # pylint: disable=too-few-public-methods + dispatch = 0 + heartbeat = 1 + identify = 2 + presence_update = 3 + voice_state_update = 4 + resume = 6 + reconnect = 7 + request_guild_members = 8 + invalid_session = 9 + hello = 10 + heartbeat_ack = 11 + +class NitroPremiumType: # pylint: disable=too-few-public-methods + none = 0 + nitro_classic = 1 + nitro = 2 + nitro_basic = 3 + +class EmbedType: # pylint: disable=too-few-public-methods + rich = 'rich' + image = 'image' + video = 'video' + gifv = 'gifv' + article = 'article' + link = 'link' + +class MessageActivityType: # pylint: disable=too-few-public-methods + join = 1 + spectate = 2 + listen = 3 + join_request = 5 + +class TeamMemberMembershipState: # pylint: disable=too-few-public-methods + invited = 1 + accepted = 2 + +class OverwriteType: # pylint: disable=too-few-public-methods + role = 0 + member = 1 + +class GuildForumSortOrderType: # pylint: disable=too-few-public-methods + latest_activity = 0 + creation_date = 1 + +class GuildForumLayoutType: # pylint: disable=too-few-public-methods + not_set = 0 + list_view = 1 + gallery_view = 2 + +class VideoQualityMode: # pylint: disable=too-few-public-methods + auto = 1 + full = 2 + +class ButtonStyle: # pylint: disable=too-few-public-methods + primary = 1 + secondary = 2 + success = 3 + danger = 4 + link = 5 + +class StickerType: # pylint: disable=too-few-public-methods + standard = 1 + guild = 2 + +class StickerFormatType: # pylint: disable=too-few-public-methods + png = 1 + apng = 2 + lottie = 3 + gif = 4 + +# Flags +class MessageFlags: # pylint: disable=too-few-public-methods + crossposted = 1 << 0 + is_crosspost = 1 << 1 + suppress_embeds = 1 << 2 + source_message_deleted = 1 << 3 + urgent = 1 << 4 + has_thread = 1 << 5 + ephemeral = 1 << 6 + loading = 1 << 7 + failed_to_mention_some_roles_in_thread = 1 << 8 + +class UserFlags: # pylint: disable=too-few-public-methods + staff = 1 << 0 + partner = 1 << 1 + hypesquad = 1 << 2 + bug_hunter_level_1 = 1 << 3 + hypesquad_online_house_1 = 1 << 6 + hypesquad_online_house_2 = 1 << 7 + hypesquad_online_house_3 = 1 << 8 + premium_early_supporter = 1 << 9 + team_pseudo_user = 1 << 10 + bug_hunter_level_2 = 1 << 14 + verified_bot = 1 << 16 + verified_developer = 1 << 17 + certified_moderator = 1 << 18 + bot_http_interactions = 1 << 19 + active_developer = 1 << 22 + +class ApplicationFlags: # pylint: disable=too-few-public-methods + gateway_presence = 1 << 12 + gateway_presence_limited = 1 << 13 + gateway_guild_members = 1 << 14 + gateway_guild_members_limited = 1 << 15 + verification_pending_guild_limit = 1 << 16 + embedded = 1 << 17 + gateway_message_content = 1 << 18 + gateway_message_content_limited = 1 << 19 + application_command_badge = 1 << 23 + +class ChannelFlags: # pylint: disable=too-few-public-methods + pinned = 1 << 1 + require_tag = 1 << 4 + +# Bit sets flags +class Permissions: + create_instant_invite = 1 << 0 + kick_members = 1 << 1 + ban_members = 1 << 2 + administrator = 1 << 3 + manage_channels = 1 << 4 + manage_guild = 1 << 5 + add_reactions = 1 << 6 + view_audit_log = 1 << 7 + priority_speaker = 1 << 8 + stream = 1 << 9 + view_channel = 1 << 10 + send_messages = 1 << 11 + send_tts_messages = 1 << 12 + manage_messages = 1 << 13 + embed_links = 1 << 14 + attach_files = 1 << 15 + read_message_history = 1 << 16 + mention_everyone = 1 << 17 + use_external_emojis = 1 << 18 + view_guild_insights = 1 << 19 + connect = 1 << 20 + speak = 1 << 21 + mute_members = 1 << 22 + deafen_members = 1 << 23 + move_members = 1 << 24 + use_vad = 1 << 25 + change_nickname = 1 << 26 + manage_nicknames = 1 << 27 + manage_roles = 1 << 28 + manage_webhooks = 1 << 29 + manage_emojis_and_stickers = 1 << 30 + use_application_commands = 1 << 31 + request_to_speak = 1 << 32 + manage_events = 1 << 33 + manage_threads = 1 << 34 + create_public_threads = 1 << 35 + create_private_threads = 1 << 36 + use_external_stickers = 1 << 37 + send_messages_in_threads = 1 << 38 + use_embedded_activities = 1 << 39 + moderate_members = 1 << 40 + +# public +class TextInputStyle: # pylint: disable=too-few-public-methods + short = 1 + paragraph = 2 diff --git a/pytecord/files.py b/pytecord/files.py new file mode 100644 index 0000000..bd4f52c --- /dev/null +++ b/pytecord/files.py @@ -0,0 +1,46 @@ +from typing import TYPE_CHECKING, TypeVar, Generic + +if TYPE_CHECKING: + from pytecord.annotations import Snowflake, Filename, Url, ProxyUrl + from pytecord.payloads import AttachmentPayload + +CT = TypeVar('CT', str, None) + +__all__ = ( + 'Attachment', +) + +class Attachment(Generic[CT]): + ''' + Attachment (File) object + + ### Magic operations + --- + + `int()` -> Attachment id + + `str()` -> Attachment url + + ``` + int(attachment) + str(attachment) + ``` + ''' + def __init__(self, *_, **data: 'AttachmentPayload') -> None: + _ = data.get + + self.id: Snowflake = int(_('id')) # pylint: disable=invalid-name + self.filename: Filename = _('filename') + self.description: str | None = _('description') + self.content_type: CT = _('content_type') + self.size: int = int(_('size')) + self.url: Url = _('url') + self.proxy_url: ProxyUrl = _('proxy_url') + self.height: int | None = _('height') + self.width: int | None = _('width') + self.ephemeral: bool = _('ephemeral', False) + + def __int__(self) -> int: + return self.id + def __str__(self) -> str: + return self.url diff --git a/pytecord/hook.py b/pytecord/hook.py new file mode 100644 index 0000000..db93847 --- /dev/null +++ b/pytecord/hook.py @@ -0,0 +1,332 @@ +''' +Webhook for connection to Discord Gateway +''' + +from asyncio import create_task, gather, get_event_loop +from asyncio import sleep as async_sleep +from dataclasses import dataclass, field +from datetime import datetime +from time import mktime +from typing import TYPE_CHECKING, Any, TypeVar + +if TYPE_CHECKING: + from aiohttp import ClientSession + +from pytecord import utils +from pytecord.app import AppClient, Context +from pytecord.channel import Channel, Message, RawMessage +from pytecord.enums import (ApplicationCommandOptionType, ApplicationCommandType, + InteractionType) +from pytecord.files import Attachment +from pytecord.listener import Listener +from pytecord.profiles import Member, User +from pytecord.role import Role +from pytecord.route import Route + +gateway_version = 10 # pylint: disable=invalid-name + +@dataclass +class _GatewayEvent: + op: int # pylint: disable=invalid-name + d: dict = field(default_factory=dict) # pylint: disable=invalid-name + s: int = 0 # pylint: disable=invalid-name + t: str = 'NONE' # pylint: disable=invalid-name + +OV = TypeVar('OV', str, float, int, bool, Member, Channel, Role) + +class _OptionSerializator: + def __new__(cls, option: dict[str, Any], resolved: list[dict[str, Any]] | None, session: 'ClientSession') -> tuple[str, OV]: + name = option['name'] + type = option['type'] + value = None + + non_resolving_types = ( + ApplicationCommandOptionType.string, + ApplicationCommandOptionType.integer, + ApplicationCommandOptionType.boolean, + ApplicationCommandOptionType.number + ) + resolving_types = { + ApplicationCommandOptionType.user: 'members*', + ApplicationCommandOptionType.channel: 'channels', + ApplicationCommandOptionType.role: 'roles', + ApplicationCommandOptionType.mentionable: 'members | roles*', + ApplicationCommandOptionType.attachment: 'attachments' + } # * - needs additional checks + + resolving_python_types = { + ApplicationCommandOptionType.user: Member, + ApplicationCommandOptionType.channel: Channel, + ApplicationCommandOptionType.role: Role, + ApplicationCommandOptionType.mentionable: Member | Role, + ApplicationCommandOptionType.attachment: Attachment + } + + if type in non_resolving_types: + value = option['value'] + else: + target_id = option['value'] + if type in ( + ApplicationCommandOptionType.user, ApplicationCommandOptionType.mentionable + ): + def _members() -> 'Member': + member_data = resolved['members'][target_id] + user_data = resolved['users'][target_id] + + user = User(session, **user_data) + return Member(session, user, **member_data) + + if type is ApplicationCommandOptionType.mentionable: + roles, members = resolved.get('roles', []), resolved.get('members', []) + + if roles and not members: members = [0 for i in range(len(roles))] # pylint: disable=multiple-statements + elif members and not roles: roles = [0 for i in range(len(members))] # pylint: disable=multiple-statements + + result_type = None + for role_id, member_id in zip(roles, members): + if role_id == target_id: + result_type = 'roles' + break + if member_id == target_id: + result_type = 'members' + break + + if result_type == 'roles': + data = resolved[result_type][target_id] + value = Role(session, **data) + elif result_type == 'members': + value = _members() + + elif type is ApplicationCommandOptionType.user: + value = _members() + else: + resolved_type_name = resolving_types[type] # For example, 'channels' + resolved_python_type: Member | Channel | Role = resolving_python_types[type] # For example, Channel + data = resolved[resolved_type_name][target_id] + + value = resolved_python_type(session, **data) + + return name, value + +class Hook: + ''' + Webhook for connection to Discord Gateway + ''' + def __init__(self, *, token: str, **options) -> None: + self.token = token + self.status = 'online' + + self._headers = { + "Authorization": f"Bot {token}", + "content-type": "application/json", + } + self._session = None + self._ws = None + self._intents = 0 + self._debug = options.get('debug', False) + self._listener = None + self._user_id = None + self._app_client = None + + def _debuging(self, data: dict): + if self._debug: + print(utils.get_hook_debug_message(data)) + + async def _get(self) -> dict | None: + try: + j = await self._ws.receive_json() + self._debuging(j) + return j + except TypeError: + return + + async def _send(self, data: dict) -> dict: + await self._ws.send_json(data) + self._debuging(data) + return data + + async def _identify(self) -> dict: + j = { + "op": 2, + "d": { + "token": self.token, + "intents": self._intents, + "properties": { + "$os": "linux", + "$browser": "disspy", + "$device": "disspy" + }, + "presence": { + "since": mktime(datetime.now().timetuple()) * 1000, + "afk": False, + "status": self.status, + "activities": [] # Not supported + } + } + } + return await self._send(j) + + async def run(self, _session, listener: Listener, app_client: AppClient, **options): + ''' + Run the hook + ''' + self._intents = options.get('intents', 0) + self._session = _session + self._listener = listener + self._app_client = app_client + + async with self._session.ws_connect( + f"wss://gateway.discord.gg/?v={gateway_version}&encoding=json" + ) as _ws: + self._ws = _ws + + data = await self._get() + await self._identify() + + await gather( + create_task(self._life(data['d']['heartbeat_interval'] / 1000)), + create_task(self._events()) + ) + + async def _register_app_commands(self, app_id): + route = Route( + '/applications/%s/commands', app_id, + method='GET', + token=self.token + ) + server_app_commands, _ = route.request() + code_app_commands = [i.eval() for i in self._app_client.commands] + + async def _async_request(__r: Route) -> Any: + return await __r.async_request(self._session, get_event_loop()) + + if code_app_commands: + equals_commands = [] # to PATCH + excess_commands = [] # to DELETE + new_commands = [] # to POST + + for code in code_app_commands: + for server in server_app_commands: + if code['name'] == server['name']: + code['id'] = server['id'] + equals_commands.append(code) + else: + if (code not in new_commands) and (code not in equals_commands): + new_commands.append(code) + if (server not in excess_commands) and (server not in equals_commands): + excess_commands.append(server) + + for eq in equals_commands: + route = Route( + '/applications/%s/commands/%s', app_id, eq['id'], + method='PATCH', + payload=eq + ) + await _async_request(route) + for ex in excess_commands: + route = Route( + '/applications/%s/commands/%s', app_id, ex['id'], + method='DELETE' + ) + await _async_request(route) + for nw in new_commands: + route = Route( + '/applications/%s/commands', app_id, + method='POST', + payload=nw + ) + await _async_request(route) + else: + for i in server_app_commands: + route = Route( + '/applications/%s/commands/%s', app_id, i['id'], + method='DELETE' + ) + await _async_request(route) + + async def _life(self, interval): + while True: + await self._send({"op": 1, "d": None, "t": None}) + + await async_sleep(interval) + + async def _events(self): + while True: + if x := await self._get(): # pylint: disable=invalid-name + event = _GatewayEvent(**x) + else: + continue + + match event.t: + case 'READY': + self._user_id = event.d['user']['id'] + await self._register_app_commands(event.d['application']['id']) + await self._listener.invoke_event('ready') + case 'MESSAGE_CREATE' | 'MESSAGE_UPDATE': + if event.d['author']['id'] != self._user_id: + message = Message(self._session, **event.d) + await self._listener.invoke_event('message', message) + case 'MESSAGE_DELETE': + raw_message = RawMessage(self._session, **event.d) + await self._listener.invoke_event('message_delete', raw_message) + case 'INTERACTION_CREATE': + ctx = Context(event.d, self.token, self._session, self) + + interaction_type = event.d['type'] + + if interaction_type is InteractionType.application_command: + command_data: dict = event.d['data'] + command_type: int = command_data['type'] + command_name: str = command_data['name'] + + if command_type is ApplicationCommandType.chat_input: + option_values = {} + if command_data.get('options', None): + option_jsons = command_data['options'] + resolved = command_data.get('resolved') + + for option_json in option_jsons: + __name, __serialized = _OptionSerializator(option_json, resolved, self._session) + option_values[__name] = __serialized + + if option_values: + await self._app_client.invoke_command( + command_name, command_type, + ctx, **option_values) + else: + await self._app_client.invoke_command( + command_name, command_type, + ctx) + else: + resolved_data = command_data['resolved'] + target_id = command_data['target_id'] + + if command_type is ApplicationCommandType.user: + resolved = User(self._session, **resolved_data['users'][target_id]) + elif command_type is ApplicationCommandType.message: + resolved = Message( + self._session, **resolved_data['messages'][target_id]) + await self._app_client.invoke_command( + command_name, command_type, + ctx, resolved) + elif interaction_type == InteractionType.modal_submit: + submit_data = event.d['data'] + inputs_values = {} + + for i in submit_data['components']: + text_input = i['components'][0] + + if text_input['value']: + inputs_values.setdefault( + text_input['custom_id'], + text_input['value'] + ) + + await self._app_client.invoke_modal_submit( + submit_data['custom_id'], + ctx, + **inputs_values + ) + case _: + if self._debug and event.t: + print(f'Unknown {event.t} event type!') diff --git a/pytecord/listener.py b/pytecord/listener.py new file mode 100644 index 0000000..358fe68 --- /dev/null +++ b/pytecord/listener.py @@ -0,0 +1,34 @@ +''' +All: Listener +''' + +class Listener: + ''' + bot listener + ''' + def __init__(self): + self.events = { + 'ready': None, + 'message': None, + 'message_delete': None + } + async def invoke_event(self, name: str, *args, **kwrgs): + ''' + Invoke event function in listener + ''' + func = self.events[name] + + if func is not None: + await func(*args, **kwrgs) + + def add_event(self, name, func): + ''' + Add event method + ''' + self.events[name] = func + + def remove_event(self, name): + ''' + Remove event method + ''' + self.events[name] = None diff --git a/pytecord/payloads.py b/pytecord/payloads.py new file mode 100644 index 0000000..c8e7625 --- /dev/null +++ b/pytecord/payloads.py @@ -0,0 +1,581 @@ +''' +Json dict payloads like objects in discord API +''' + +from typing import Literal, NewType, TypedDict + +from pytecord.enums import (ApplicationCommandOptionType, ApplicationCommandType, + ApplicationFlags, ButtonStyle, ChannelFlags, + ChannelType, ComponentType, EmbedType, + GuildForumLayoutType, GuildForumSortOrderType, + InteractionType, MessageActivityType, MessageFlags, + MessageType, NitroPremiumType, OverwriteType, + StickerFormatType, StickerType, + TeamMemberMembershipState, TextInputStyle, UserFlags, + VideoQualityMode) + +# Fixing bugs :) +_this = NewType('_this', type) +_MessageComponentPayload = NewType('_MessageComponentPayload', type) +_StickerPayload = NewType('_StickerPayload', type) + +# Typing +HashStr = NewType('HashStr', str) +IntColor = NewType('IntColor', int) +LocaleStr = NewType('LocaleStr', str) +BitSet = NewType('BitSet', str) +UnicodeStr = NewType('UnicodeStr', str) +Iso8601Timestamp = NewType('Iso8601Timestamp', str) + +class ApplicationCommandOptionChoicePayload(TypedDict): + ''' + Application command option choice payload + ''' + name: str + name_localizations: dict[str, str] | None + value: str | int | float + +class ApplicationCommandOptionPayload(TypedDict): + ''' + Application command option payload + ''' + type: ApplicationCommandOptionType + name: str + name_localizations: dict[str, str] | None + description: str + description_localizations: dict[str, str] | None + required: bool | None + choices: list[ApplicationCommandOptionChoicePayload] | None + options: list[_this] | None + channel_types: list[ChannelType] | None + min_value: int | float | None + max_value: int | float | None + min_length: int | None + max_length: int | None + autocomplete: bool | None + +class ApplicationCommandPayload(TypedDict): + ''' + Application command payload + ''' + id: int + type: ApplicationCommandType | None + application_id: int + guild_id: int | None + name: str + name_localizations: dict[str, str] | None + description: str | None + description_localizations: dict[str, str] | None + options: list[ApplicationCommandOptionPayload] | None + default_member_permissions: str | None + dm_permission: bool | None + default_permission: bool | None + nsfw: bool | None + version: int + +class UserPayload(TypedDict): + ''' + User payload + ''' + id: int + username: str + discriminator: str + avatar: HashStr | None + bot: bool | None + system: bool | None + mfa_enabled: bool | None + banner: HashStr | None + accent_color: IntColor | None + locale: LocaleStr + # Needs email Oauth2 scope + verified: bool | None + email: str | None + # + flags: UserFlags | None + premium_type: NitroPremiumType | None + public_flags: UserFlags | None + +class RoleTagsPayload(TypedDict): + ''' + Role tags payload + ''' + bot_id: int | None + integration_id: int | None + premium_subscriber: bool | None + subscription_listing_id: int | None + available_for_purchase: bool | None + guild_connections: bool | None + +class RolePayload(TypedDict): + ''' + Role payload + ''' + id: int + name: str + color: IntColor + hoist: bool + icon: HashStr | None + unicode_emoji: UnicodeStr | None + position: int + permissions: BitSet + managed: bool + mentionable: bool + tags: RoleTagsPayload | None + +class ChannelMentionPayload(TypedDict): + ''' + Channel mention payload + ''' + id: int + guild_id: int + type: ChannelType + name: str + +class AttachmentPayload(TypedDict): + ''' + Attachment payload + ''' + id: int + filename: str + description: str | None + content_type: str | None + size: int + url: str + proxy_url: str + height: int | None + width: int | None + ephemeral: bool | None + +class EmbedFooterPayload(TypedDict): + ''' + Message embed footer payload + ''' + text: str + icon_url: str | None + proxy_icon_url: str | None + +class EmbedImagePayload(TypedDict): + ''' + Message embed image payload + ''' + url: str + proxy_url: str | None + height: int | None + width: int | None + +class EmbedThumbnailPayload(TypedDict): + ''' + Message embed thumbnail payload + ''' + url: str + proxy_url: str | None + height: int | None + width: int | None + +class EmbedVideoPayload(TypedDict): + ''' + Message embed video payload + ''' + url: str | None + proxy_url: str | None + height: int | None + width: int | None + +class EmbedProviderPayload(TypedDict): + ''' + Message embed provider payload + ''' + name: str | None + url: str | None + +class EmbedAuthorPayload(TypedDict): + ''' + Message embed author payload + ''' + name: str + url: str | None + icon_url: str | None + proxy_icon_url: str | None + +class EmbedFieldPayload(TypedDict): + ''' + Message embed field payload + ''' + name: str + value: str + inline: bool | None + +class EmbedPayload(TypedDict): + ''' + Message embed payload + ''' + title: str | None + type: EmbedType | None + description: str | None + url: str | None + timestamp: Iso8601Timestamp | None + color: IntColor | None + footer: EmbedFooterPayload | None + image: EmbedImagePayload | None + thumbnail: EmbedThumbnailPayload | None + video: EmbedVideoPayload | None + provider: EmbedProviderPayload | None + author: EmbedAuthorPayload | None + fields: list[EmbedFieldPayload] | None + +class EmojiPayload(TypedDict): + ''' + Emoji payload + ''' + id: int + name: str | None + roles: list[int] | None + user: UserPayload | None + require_colons: bool | None + managed: bool | None + animated: bool | None + available: bool | None + +class ReactionPayload(TypedDict): + ''' + Message reaction payload + ''' + count: int + me: bool + emoji: EmojiPayload + +class MessageActivityPayload(TypedDict): + ''' + Message activity payload + ''' + type: MessageActivityType + party_id: str | None + +class TeamMemberPayload(TypedDict): + ''' + Application team member payload + ''' + membership_state: TeamMemberMembershipState + permissions: list[str] + team_id: int + user: UserPayload + +class TeamPayload(TypedDict): + ''' + Application team payload + ''' + icon: HashStr | None + id: int + members: list[TeamMemberPayload] + name: str + owner_user_id: int + +class InstallParamsPayload(TypedDict): + ''' + Application install params payload + ''' + scopes: list[str] + permissions: str + +class ApplicationPayload(TypedDict): + ''' + Application payload + ''' + id: int + name: str + icon: HashStr | None + description: str + rpc_origins: list[str] | None + bot_public: bool + bot_require_code_grant: bool + terms_of_service_url: str | None + privacy_policy_url: str | None + owner: UserPayload + summary: str # Soon will be removed in v11! + verify_key: str + team: TeamPayload | None + guild_id: int | None + primary_sku_id: int | None + slug: str | None + cover_image: HashStr | None + flags: ApplicationFlags | None + tags: list[str] | None + install_params: InstallParamsPayload | None + custom_install_url: str | None + role_connections_verification_url: str | None + +class MessageReferencePayload(TypedDict): + ''' + Message reference payload + ''' + message_id: int | None + channel_id: int | None + guild_id: int | None + fail_if_not_exists: bool | None + +class GuildMemberPayload(TypedDict): + ''' + Guild member payload + ''' + user: UserPayload | None + nick: str | None + avatar: HashStr | None + roles: list[int] + joined_at: Iso8601Timestamp + premium_since: Iso8601Timestamp | None + deaf: bool + mute: bool + pending: bool | None + permissions: str | None + communication_disabled_until: Iso8601Timestamp | None + +class MessageInteractionPayload(TypedDict): + ''' + Message interaction payload + ''' + id: int + type: InteractionType + name: str + user: UserPayload + member: GuildMemberPayload | None + +class OverwritePayload(TypedDict): + ''' + Channel overwrite payload + ''' + id: int + type: OverwriteType + allow: str + deny: str + +class ThreadMetadataPayload(TypedDict): + ''' + Thread (channel with 10-12 type) metadata payload + ''' + archived: bool + auto_archive_duration: Literal[60, 1440, 4320, 10080] + archive_timestamp: Iso8601Timestamp + locked: bool + invitable: bool | None + create_timestamp: Iso8601Timestamp | None + +class ThreadMemberPayload(TypedDict): + ''' + Thread (channel with 10-12 type) member payload + ''' + id: int | None + user_id: int | None + join_timestamp: Iso8601Timestamp + flags: int + member: GuildMemberPayload | None + +class ForumTagPayload(TypedDict): + ''' + Forum (channel with 15 type) tag payload + ''' + id: int + name: str + moderated: bool + emoji_id: int | None + emoji_name: str | None + +class ChannelPayload(TypedDict): + ''' + Channel payload + ''' + id: int + type: ChannelType + guild_id: int | None + position: int | None + permission_overwrites: list[OverwritePayload] | None + name: str | None + topic: str | None + nsfw: bool | None + last_message_id: int | None + bitrate: int | None + user_limit: int | None + rate_limit_per_user: int | None + recipients: list[UserPayload] | None + icon: HashStr | None + owner_id: int | None + application_id: int | None + parent_id: int | None + last_pin_timestamp: Iso8601Timestamp | None + rtc_region: str | None + video_quality_mode: VideoQualityMode | None + message_count: int | None + member_count: int | None + thread_metadata: ThreadMetadataPayload | None + member: ThreadMemberPayload | None + default_auto_archive_duration: Literal[60, 1440, 4320, 10080] | None + permissions: str | None + flags: ChannelFlags | None + total_message_sent: int | None + available_tags: list[ForumTagPayload] | None + applied_tags: list[int] | None + default_reaction_emoji: ReactionPayload | None + default_thread_rate_limit_per_user: int | None + default_sort_order: GuildForumSortOrderType | None + default_forum_layout: GuildForumLayoutType | None + +# Message components +class ActionRowPayload(TypedDict): + ''' + Action row component payload + ''' + type: Literal[1] + components: list[_MessageComponentPayload] + +class ButtonPayload(TypedDict): + ''' + Button component payload + ''' + type: Literal[2] + style: ButtonStyle + label: str | None + emoji: EmojiPayload | None + custom_id: str | None + url: str | None + disabled: bool | None + +class SelectOptionPayload(TypedDict): + ''' + Select menu option payload + ''' + label: str + value: str + description: str | None + emoji: EmojiPayload | None + default: bool | None + +class SelectMenuPayload(TypedDict): + ''' + Select menu component payload + ''' + type: Literal[3, 5, 6, 7, 8] + custom_id: str + options: list[SelectOptionPayload] | None + channel_types: list[ChannelType] | None + placeholder: str | None + min_values: int | None + max_values: int | None + disabled: bool | None + +class TextInputPayload(TypedDict): + ''' + Text input component payload + ''' + type: Literal[4] + custom_id: str + style: TextInputStyle + label: str + min_length: int | None + max_length: int | None + required: bool | None + value: str | None + placeholder: str | None +# # # # # # + +class MessageComponentPayload(TypedDict): + ''' + Message component payload + ''' + type: ComponentType + components: list[_this] + style: ButtonStyle | TextInputStyle | None + label: str | None + emoji: EmojiPayload | None + custom_id: str | None + url: str | None + disabled: bool | None + options: list[SelectOptionPayload] | None + channel_types: list[ChannelType] | None + placeholder: str | None + min_values: int | None + max_values: int | None + min_length: int | None + max_length: int | None + required: bool | None + value: str | None + +class MessageStickerItemPayload(TypedDict): + ''' + Message sticker item payload + ''' + id: int + name: str + format_type: StickerFormatType + +class StickerPackPayload(TypedDict): + ''' + Message sticker pack payload + ''' + id: int + stickers: list[_StickerPayload] + name: str + sku_id: int + cover_sticker_id: int | None + description: str + banner_asset_id: int | None + +class StickerPayload(TypedDict): + ''' + Message sticker payload + ''' + id: int + pack_id: int | None + name: str + description: str | None + tags: str + asset: str # Deprecated + type: StickerType + format_type: StickerFormatType + available: bool | None + guild_id: int | None + user: UserPayload | None + sort_value: int | None + +class RoleSubscriptionData(TypedDict): + ''' + Role subscription data in message + ''' + role_subscription_listing_id: int + tier_name: str + total_months_subscribed: int + is_renewal: bool + +class MessagePayload(TypedDict): + ''' + Channel message payload + ''' + id: int + channel_id: int + author: UserPayload + content: str | None + timestamp: Iso8601Timestamp + edited_timestamp: Iso8601Timestamp | None + tts: bool + mention_everyone: bool + mentions: list[UserPayload] | None + mention_roles: list[RolePayload] | None + mention_channels: list[ChannelMentionPayload] | None + attachments: list[AttachmentPayload] | None + embeds: list[EmbedPayload] | None + reactions: list[ReactionPayload] | None + nonce: int | str | None + pinned: bool + webhook_id: int | None + type: MessageType + activity: MessageActivityPayload | None + application: ApplicationPayload | None + application_id: int | None + message_reference: MessageReferencePayload | None + flags: MessageFlags + referenced_message: _this | None + interaction: MessageInteractionPayload | None + thread: ChannelPayload | None + components: list[MessageComponentPayload] | None + sticker_items: list[MessageStickerItemPayload] | None + stickers: list[StickerPayload] | None + position: int | None + role_subscription_data: RoleSubscriptionData | None diff --git a/pytecord/profiles.py b/pytecord/profiles.py new file mode 100644 index 0000000..00fd61e --- /dev/null +++ b/pytecord/profiles.py @@ -0,0 +1,79 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pytecord.payloads import UserPayload, GuildMemberPayload + from aiohttp import ClientSession + +__all__ = ( + 'User', + 'Member', +) + +class User: + ''' + User object in discord API + + ### Magic operations + --- + + `str()` -> User fullname + + `int()` -> User id + + ``` + str(user) # name#1234 + int(user) # 907966263270207519 + ``` + ''' + def __init__(self, session: 'ClientSession', **data: 'UserPayload') -> None: + _ = data.get + self._session = session + + self.id: int = int(_('id')) + self.username: str = _('username') + self.tag: str = _('discriminator') + self.fullname: str = f'{self.username}#{self.tag}' + self.bot: bool | None = _('bot', False) + self.system: bool | None = _('system', False) + self.mfa_enabled: bool | None = _('mfa_enabled', False) + self.flags: int | None = _('flags', 0) + self.public_flags: int | None = _('public_flags', 0) + + def __str__(self) -> str: + return self.fullname + def __int__(self) -> int: + return self.id + +class Member: + ''' + Standart guild member in discord API + + ### Magic operations + --- + + ... + ''' + def __init__(self, session: 'ClientSession', user: User | None,**data: 'GuildMemberPayload') -> None: + _ = data.get + + if not user: + user = _('user') + + self.user: User | None = User(session, **user) if isinstance(user, dict) else user + self.nick: str | None = nick if (nick := _('nick')) else self.user.username + self.avatar: str | None = _('avatar') + self.roles: list[str] = _('roles', []) + self.joined_at: str = _('joined_at') + self.premium_since: str | None = _('premium_since') + self.deaf: bool = _('deaf', False) + self.mute: bool = _('mute', False) + self.pending: bool | None = _('pending', False) + self.permissions: str | None = _('permissions', '0') + self.communication_disabled_until: str | None = _('communication_disabled_until') + + def __str__(self) -> str: + return self.nick + def __int__(self) -> int: + if self.user: + return self.user.id + return 0 diff --git a/pytecord/py.typed b/pytecord/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pytecord/role.py b/pytecord/role.py new file mode 100644 index 0000000..6f20dae --- /dev/null +++ b/pytecord/role.py @@ -0,0 +1,58 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pytecord.payloads import RoleTagsPayload, RolePayload + from aiohttp import ClientSession + +__all__ = ( + 'RoleTags', + 'Role', +) + +class RoleTags: + def __init__(self, **data: 'RoleTagsPayload') -> None: + _ = data.get + + self.bot_id: int | None = _('bot_id') + self.integration_id: int | None = _('integration_id') + self.premium_subscriber: bool | None = _('premium_subscriber', False) + self.subscription_listing_id: int | None = _('subscription_listing_id') + self.available_for_purchase: bool | None = _('available_for_purchase', False) + self.guild_connections: bool | None = _('guild_connections') + +class Role: + ''' + Guild role + + ### Magic operations + --- + + `int()` -> Role id + + `str()` -> Role name + + ``` + int(role) + str(role) + ``` + ''' + def __init__(self, session: 'ClientSession', **data: 'RolePayload') -> None: + self._session = session + _ = data.get + + self.id: int = int(_('id')) # pylint: disable=invalid-name + self.name: str = _('name') + self.color: int = _('color', 0) + self.hoist: bool = _('hoist', False) + self.icon: str | None = _('icon') + self.unicode_emoji: str | None = _('unicode_emoji') + self.position: int = int(_('position')) + self.permissions: str = _('permissions') + self.managed: bool = _('managed', False) + self.mentionable: bool = _('mentionable', False) + self.tags: RoleTags | None = RoleTags(**x) if (x := _('tags')) else None # pylint: disable=invalid-name + + def __int__(self) -> int: + return self.id + def __str__(self) -> str: + return self.name diff --git a/pytecord/route.py b/pytecord/route.py new file mode 100644 index 0000000..0e9c71f --- /dev/null +++ b/pytecord/route.py @@ -0,0 +1,132 @@ +''' +~Route~ - is a class what is using for responding in discord API + +All: +---- + +1. ~Route~ +... +''' + +from asyncio import gather +from typing import TYPE_CHECKING, Iterable, Literal, TypeAlias, Optional + +from aiohttp.client_exceptions import ContentTypeError +from requests import delete as DELETE +from requests import get as GET +from requests import head as HEAD +from requests import options as OPTIONS +from requests import patch as PATCH +from requests import post as POST +from requests import put as PUT +from requests.exceptions import JSONDecodeError + +from pytecord import utils + +if TYPE_CHECKING: + from asyncio import AbstractEventLoop + + from aiohttp import ClientSession + from requests import Response + +API_VERSION = 10 +BASE = f'https://discord.com/api/v{API_VERSION}' + +_HTTPRequestResult: TypeAlias = tuple[dict | str, int] + +class Route: + def __init__( + self, + endpoint: Optional[str] = '', + *params: Iterable[str], + method: Literal['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'HEAD'] = 'GET', + token: Optional[str] = '', + payload: Optional[dict] = {} + ) -> None: + self.endpoint = (endpoint % params) if endpoint and params else endpoint + self.url = BASE + self.endpoint + self.method = method + self.hdrs = utils.auth(token) if token else None + self.data = payload + + def request(self) -> _HTTPRequestResult: + ''' + Route.request() + --------------- + + Sends request with info in object + ''' + funcs = { + 'GET': GET, + 'POST': POST, + 'PUT': PUT, + 'DELETE': DELETE, + 'PATCH': PATCH, + 'OPTIONS': OPTIONS, + 'HEAD': HEAD, + } + func = funcs[self.method] + + if self.data: + resp: 'Response' = func( + url=self.url, + json=self.data, + headers=self.hdrs, + timeout=2 + ) + else: + resp: 'Response' = func( + url=self.url, + headers=self.hdrs, + timeout=2 + ) + + try: + return resp.json(), resp.status_code + except JSONDecodeError: + return resp.text, resp.status_code + + async def _async_request_task(self, session: 'ClientSession') -> _HTTPRequestResult: + funcs = { + 'GET': session.get, + 'POST': session.post, + 'PUT': session.put, + 'DELETE': session.delete, + 'PATCH': session.patch, + 'OPTIONS': session.options, + 'HEAD': session.head, + } + func = funcs[self.method] + + async with func( + url=self.url, + json=self.data, + headers=self.hdrs, + timeout=2, + ) as resp: + try: + j = await resp.json() + return j, resp.status + except ContentTypeError: + try: + j = await resp.text() + return j, resp.status + except ContentTypeError: + return None, resp.status + + + async def async_request(self, session: 'ClientSession', loop: 'AbstractEventLoop'): + ''' + Route.async_request() + --------------------- + + Sends async request with info in object + + Arguments: + ========== + + ~session~: The aiohttp client session + ~loop~: The current asyncio event loop + ''' + t, = await gather(loop.create_task(self._async_request_task(session))) + return t diff --git a/pytecord/ui.py b/pytecord/ui.py new file mode 100644 index 0000000..2a93312 --- /dev/null +++ b/pytecord/ui.py @@ -0,0 +1,130 @@ +from typing import TYPE_CHECKING, Callable, Generic, Iterable, TypeVar, TypeAlias + +from pytecord.enums import ComponentType, TextInputStyle + +if TYPE_CHECKING: + from pytecord.app import Context + +T = TypeVar('T', bound=str) + +TextInputStyleKO: TypeAlias = int + +__all__ = ( + 'TextInput', + 'Modal', +) + +class TextInput(Generic[T]): + def _check_len(self, value: str | int, min: int, max: int) -> bool: # pylint: disable=redefined-builtin + if isinstance(value, str): + return len(value) <= max and min <= len(value) + elif isinstance(value, int): + return value <= max and min <= value + def _check_for(self, value: Iterable, min: int, max: int) -> bool: # pylint: disable=redefined-builtin + for i in value: + b = self._check_len(i, min, max) + if not b: + return False + return True + def _check_return( + self, value: str | int | Iterable, l: tuple[int, int], + func: Callable[[str | int | Iterable, int, int], bool]): + min, max = l # pylint: disable=redefined-builtin + + if value is None: + return value + + if func(value, min, max): + return value + else: + raise ValueError( + f'This literal {value} is too long or fewer! Maximun is {max}, minimun is {min}' + ) + + def __init__(self, + custom_id: str, + label: str, + style: TextInputStyleKO = TextInputStyle.short, + length: tuple[int, int] = (0, 4000), # (min, max) + required: bool = False, + value: T | None = None, + placeholder: str | None = None) -> None: + self.custom_id = custom_id + self.label = self._check_return(label, (1, 45), self._check_len) + self.style = style if style in list(range(1, 3)) else TextInputStyle.short + self.length = self._check_return(length, (0, 4000), self._check_for) + self.required = required + self.value = self._check_return(value, (1, 4000), self._check_len) + self.placeholder = self._check_return(placeholder, (1, 100), self._check_len) + + def eval(self) -> dict: + return { + 'type': ComponentType.text_input, + 'custom_id': self.custom_id, + 'style': self.style, + 'label': self.label, + 'min_length': self.length[0], + 'max_length': self.length[1], + 'required': self.required, + 'value': self.value, + 'placeholder': self.placeholder + } + + +class Modal: + ''' + Object for sending guis in discord + + ``` + class MyModal(Modal, custom_id='mymodal', title='My modal title'): + inputs = [ + TextInput( + custom_id='hello', + label='Hello', + style=TextInputStyle.short, + length=(1,10), + required=True, + value='Hello', # Default value + placeholder='Please type any text...' + ) + ] + ``` + ''' + title: str + custom_id: str + inputs: list[TextInput] + + def __init_subclass__(cls, *, custom_id: str, title: str) -> None: + cls.custom_id = custom_id + cls.title = title + + def eval(self) -> dict: + rows_json = [] + for i in self.inputs: + rows_json.append({ + 'type': ComponentType.action_row, + 'components': [i.eval()] + }) + + return { + 'custom_id': self.custom_id, + 'title': self.title, + 'components': rows_json + } + + async def submit(self, ctx: 'Context', **inputs: dict[str, str]): + ''' + Modal submit event. + This event is executing when user click to 'Submit' button. + + ``` + # In your modal object + async def submit(self, ctx: Context, **inputs): + hello = inputs['hello'] # 'hello' is a custom id + await ctx.send_message('Your value:', hello) + # the code below is equivalent to the code above + async def submit(self, ctx: Context, hello: str): + await ctx.send_message('Your value': hello) + ``` + ''' + raise NotImplementedError('You must implement this method!') diff --git a/pytecord/utils.py b/pytecord/utils.py new file mode 100644 index 0000000..a7f6a22 --- /dev/null +++ b/pytecord/utils.py @@ -0,0 +1,57 @@ +''' +Utils for simpler developerment disspy +''' + +from pytecord.enums import GatewayOpcode, MessageFlags + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from pytecord.annotations import Strable + +def auth(token: str): + ''' + Auth the user with token + ''' + return { + 'Authorization': f'Bot {token}', + 'content-type': 'application/json', + } + +def get_token_from_auth(hdrs: dict[str, Any]): + ''' + Get token from auth headers + ''' + return hdrs['Authorization'].split(' ')[1] + +def get_hook_debug_message(data: dict) -> str: + ''' + Get webhook debuging message + ''' + res = f"S{data.get('s', '')} " + + if data.get('op') == GatewayOpcode.dispatch: + res += data.get('t', '') + else: + res += f"OP{data.get('op', 0)}" + + res += f" | {str(data.get('d', {}))}" + return res + +def message_payload(*strings: list['Strable'], sep: str = ' ', ephemeral: bool = False, tts: bool = False) -> dict[str, Any]: + content = '' + for i in strings: + content += (str(i) + sep) + content = content.removesuffix(sep) + + embeds = None # TODO: Add embed support + allowed_mentions = None # TODO: Add allowed mentions support + flags = MessageFlags.ephemeral if ephemeral else 0 + + return { + 'tts': tts, + 'content': content, + 'embeds': embeds, + 'allowed_mentions': allowed_mentions, + 'flags': flags, + }