├── .github └── workflows │ └── python-publish.yml ├── .gitignore ├── LICENSE ├── README.md ├── dico_interaction ├── __init__.py ├── client.py ├── command.py ├── component.py ├── context.py ├── deco.py ├── exception.py ├── modal.py ├── utils.py └── webserver.py ├── docs ├── Makefile ├── api.rst ├── api │ ├── dico_interaction.client.rst │ ├── dico_interaction.command.rst │ ├── dico_interaction.component.rst │ ├── dico_interaction.context.rst │ ├── dico_interaction.deco.rst │ ├── dico_interaction.exception.rst │ ├── dico_interaction.rst │ ├── dico_interaction.utils.rst │ ├── dico_interaction.webserver.rst │ └── modules.rst ├── conf.py ├── index.rst └── make.bat ├── readthedocs.yml ├── requirements.txt └── setup.py /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | jobs: 16 | deploy: 17 | 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Set up Python 23 | uses: actions/setup-python@v2 24 | with: 25 | python-version: '3.x' 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install build 30 | - name: Build package 31 | run: python -m build 32 | - name: Publish package 33 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 34 | with: 35 | user: __token__ 36 | password: ${{ secrets.PYPI_API_TOKEN }} 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | __pycache__ 3 | test 4 | dico 5 | _build 6 | _static -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 dico-api 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dico-interaction 2 | Interaction module for dico. 3 | 4 | ## Features 5 | - Webserver included, powered by aiohttp. 6 | - Easier interaction usage. 7 | 8 | ## Installation 9 | ``` 10 | pip install -U dico-interaction 11 | ``` 12 | 13 | ## Example 14 | 15 | ### Gateway Client 16 | 17 | ```py 18 | from dico import Client, Button, ActionRow, ButtonStyles 19 | from dico_interaction import InteractionClient, InteractionContext 20 | 21 | client = Client("BOT_TOKEN") 22 | interaction = InteractionClient(client=client) 23 | 24 | 25 | @interaction.slash(name="hello", description="Say hello.") 26 | async def hello(ctx: InteractionContext): 27 | button = Button(style=ButtonStyles.PRIMARY, label="Hello!", custom_id=f"hello{ctx.id}") 28 | await ctx.send("Hello, World!", components=[ActionRow(button)]) 29 | 30 | 31 | @interaction.context_menu(name="say", menu_type=3) 32 | async def say_menu(ctx: InteractionContext): 33 | await ctx.send(f"You said: {ctx.target.content}") 34 | 35 | 36 | @interaction.component_callback("hello") 37 | async def hello_callback(ctx: InteractionContext): 38 | await ctx.send("Hello again!", ephemeral=True) 39 | 40 | client.run() 41 | 42 | ``` 43 | 44 | ### Webserver 45 | ```py 46 | import ssl # SSL is forced to register your webserver URL to discord. 47 | from dico import Button, ActionRow, ButtonStyles 48 | from dico_interaction import InteractionClient, InteractionWebserver, InteractionContext 49 | 50 | bot_token = "" 51 | bot_public_key = "" 52 | 53 | interaction = InteractionClient(respond_via_endpoint=False) 54 | server = InteractionWebserver(bot_token, bot_public_key, interaction) 55 | 56 | 57 | @interaction.slash(name="hello", description="Say hello.") 58 | async def hello(ctx: InteractionContext): 59 | button = Button(style=ButtonStyles.PRIMARY, label="Hello!", custom_id=f"hello{ctx.id}") 60 | await ctx.send("Hello, World!", components=[ActionRow(button)]) 61 | 62 | 63 | @interaction.context_menu(name="say", menu_type=3) 64 | async def say_menu(ctx: InteractionContext): 65 | await ctx.send(f"You said: {ctx.target.content}") 66 | 67 | 68 | @interaction.component_callback("hello") 69 | async def hello_callback(ctx: InteractionContext): 70 | await ctx.send("Hello again!", ephemeral=True) 71 | 72 | ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) 73 | ssl_context.load_cert_chain("cert.pem", "privkey.pem") 74 | server.run(host='0.0.0.0', port=1337, ssl_context=ssl_context) 75 | 76 | ``` 77 | -------------------------------------------------------------------------------- /dico_interaction/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | dico-interaction 3 | ~~~~~~~~~~~~~~~~~~~~~~~~ 4 | Interaction module for dico. 5 | :copyright: (c) 2021 dico-api 6 | :license: MIT 7 | """ 8 | 9 | from .client import InteractionClient 10 | from .command import InteractionCommand, AutoComplete, autocomplete 11 | from .component import ComponentCallback 12 | from .context import InteractionContext 13 | from .deco import command, slash, context_menu, component_callback, checks, option, modal_callback 14 | from .webserver import InteractionWebserver 15 | 16 | __version__ = "0.0.9" 17 | -------------------------------------------------------------------------------- /dico_interaction/client.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import typing 3 | import asyncio 4 | import logging 5 | import warnings 6 | import traceback 7 | 8 | from dico import ( 9 | ApplicationCommand, 10 | ApplicationCommandTypes, 11 | ApplicationCommandOption, 12 | ApplicationCommandInteractionDataOption, 13 | ApplicationCommandOptionType, 14 | Snowflake, 15 | Client 16 | ) 17 | 18 | from .command import InteractionCommand, AutoComplete 19 | from .command import autocomplete as autocomplete_deco 20 | from .deco import command as command_deco 21 | from .component import ComponentCallback 22 | from .context import InteractionContext 23 | from .exception import AlreadyExists, NotExists 24 | from .modal import ModalCallback 25 | 26 | 27 | class InteractionClient: 28 | """ 29 | This handles all interaction. 30 | 31 | .. note:: 32 | - ``auto_register_commands`` must be enabled to properly respond via webserver. 33 | - Attribute ``interaction`` will be automatically added to your websocket client if you pass param ``client``. 34 | 35 | :param loop: Asyncio loop instance to use in this client. Default ``asyncio.get_event_loop()``. 36 | :param respond_via_endpoint: Whether to respond via endpoint, which is for gateway response. Otherwise, set to ``False``. Default ``True``. 37 | :param client: Optional dico client. Passing this enables automatic command register, wait_interaction, and auto event registration. 38 | :param auto_register_commands: Whether to automatically register commands. Default ``False``. 39 | :param guild_id_lock: Guild ID to force-apply to all commands. This is useful for testing commands. 40 | 41 | :ivar loop: asyncio Loop of the client. 42 | :ivar commands: Dict of commands registered to the client. 43 | :ivar subcommands: Dict of subcommands registered to the client. 44 | :ivar subcommand_groups: Dict of subcommand groups registered to the client. 45 | :ivar components: Dict of component callbacks registered to the client. 46 | :ivar logger: Logger of the client. 47 | :ivar respond_via_endpoint: Whether to automatically register commands. 48 | :ivar guild_id_lock: Guild ID that will be force-applied to all commands. 49 | """ 50 | def __init__(self, 51 | *, 52 | loop: asyncio.AbstractEventLoop = None, 53 | respond_via_endpoint: bool = True, 54 | client: typing.Optional[Client] = None, 55 | auto_register_commands: bool = False, 56 | guild_id_lock: typing.Optional[Snowflake.TYPING] = None, 57 | guild_ids_lock: typing.Optional[typing.List[Snowflake.TYPING]] = None, 58 | context_cls: typing.Type[InteractionContext] = InteractionContext): 59 | self.loop = loop or asyncio.get_event_loop() 60 | 61 | # Storing commands separately is to handle easily. 62 | self.commands = {} 63 | self.subcommands = {} 64 | self.subcommand_groups = {} 65 | 66 | self.components = {} 67 | self.autocompletes = {} 68 | self.modals = {} 69 | 70 | self.logger = logging.getLogger("dico.interaction") 71 | self.respond_via_endpoint = respond_via_endpoint 72 | if guild_id_lock and guild_ids_lock: 73 | raise ValueError("You can't set both guild_id and guild_ids") 74 | elif guild_id_lock: 75 | warnings.warn("guild_id_lock is deprecated, use guild_ids_lock instead.", DeprecationWarning, stacklevel=2) 76 | guild_ids_lock = [guild_id_lock] 77 | self.guild_id_locks = guild_ids_lock 78 | self.context_cls = context_cls 79 | self.client = client 80 | if self.client is not None: 81 | self.client.interaction = self 82 | 83 | if auto_register_commands and not self.client: 84 | raise ValueError("You must pass dico.Client to use auto_register_commands in InteractionClient.") 85 | elif auto_register_commands: 86 | self.loop.create_task(self.register_commands()) 87 | 88 | if self.client: 89 | self.client.on_interaction_create = self.receive 90 | 91 | async def register_commands(self): 92 | """ 93 | Automatically registers command to discord. 94 | """ 95 | await self.client.wait_ready() 96 | self.logger.info("Registering commands...") 97 | commands = self.export_commands() 98 | if commands["global"]: 99 | await self.client.bulk_overwrite_application_commands(*commands["global"]) 100 | self.logger.info(f"Successfully registered global commands.") 101 | if commands["guild"]: 102 | for k, v in commands["guild"].items(): 103 | await self.client.bulk_overwrite_application_commands(*v, guild=k) 104 | self.logger.info(f"Successfully registered guild commands at {k}.") 105 | 106 | async def receive(self, interaction: InteractionContext) -> typing.Optional[dict]: 107 | """ 108 | Receive and handle interaction. 109 | 110 | .. note:: 111 | If ``respond_via_endpoint`` is set to ``False``, you can get initial response as dict by awaiting. 112 | 113 | :param interaction: Interaction received. 114 | :type interaction: :class:`.context.InteractionContext` 115 | :return: Optional[dict] 116 | """ 117 | if not isinstance(interaction, self.context_cls): 118 | interaction = self.context_cls.from_interaction(interaction, self.logger) 119 | if self.client: 120 | self.client.dispatch("interaction", interaction) 121 | if interaction.type.application_command: 122 | target = self.get_command(interaction) 123 | elif interaction.type.message_component: 124 | target = self.components.get(interaction.data.custom_id) 125 | if not target: 126 | maybe = [x for x in self.components if interaction.data.custom_id.startswith(x)] 127 | if maybe: 128 | target = self.components.get(maybe[0]) 129 | elif interaction.type.application_command_autocomplete: 130 | target = self.get_autocomplete(interaction) 131 | elif interaction.type.modal_submit: 132 | target = self.modals.get(interaction.data.custom_id) 133 | if not target: 134 | maybe = [x for x in self.modals if interaction.data.custom_id.startswith(x)] 135 | if maybe: 136 | target = self.modals.get(maybe[0]) 137 | else: 138 | return 139 | 140 | if not target: 141 | return 142 | 143 | self.loop.create_task(self.handle_interaction(target, interaction)) 144 | # await self.handle_command(target, interaction) 145 | 146 | if not self.respond_via_endpoint: 147 | resp = await interaction.response 148 | return resp.to_dict() 149 | 150 | def get_command(self, interaction: InteractionContext) -> typing.Optional[InteractionCommand]: 151 | """ 152 | Gets command based on interaction received. 153 | 154 | :param InteractionContext interaction: Interaction received. 155 | :return: Optional[InteractionCommand] 156 | """ 157 | subcommand_group = self.__extract_subcommand_group(interaction.data.options) 158 | subcommand = self.__extract_subcommand(subcommand_group.options if subcommand_group else interaction.data.options) 159 | if subcommand_group: 160 | return self.subcommand_groups.get(interaction.data.name, {}).get(subcommand_group.name, {}).get(subcommand.name) 161 | elif subcommand: 162 | return self.subcommands.get(interaction.data.name, {}).get(subcommand.name) 163 | else: 164 | return self.commands.get(interaction.data.name) 165 | 166 | @staticmethod 167 | def __extract_subcommand_group(options: typing.List[ApplicationCommandInteractionDataOption]): 168 | if options: 169 | option = options[0] # Only one option is passed if it is subcommand group. 170 | if option.type.sub_command_group: 171 | return option 172 | 173 | @staticmethod 174 | def __extract_subcommand(options: typing.List[ApplicationCommandInteractionDataOption]): 175 | if options: 176 | option = options[0] # Only one option is passed if it is subcommand. 177 | if option.type.sub_command: 178 | return option 179 | 180 | def get_autocomplete(self, interaction: InteractionContext) -> typing.Optional[AutoComplete]: 181 | """ 182 | Gets autocomplete based on interaction received. 183 | 184 | :param InteractionContext interaction: Interaction received. 185 | :return: Optional[AutoComplete] 186 | """ 187 | subcommand_group = self.__extract_subcommand_group(interaction.data.options) 188 | subcommand = self.__extract_subcommand(subcommand_group.options if subcommand_group else interaction.data.options) 189 | option = [x for x in (subcommand_group.options if subcommand_group else subcommand.options if subcommand else interaction.data.options) if x.focused][0] 190 | if subcommand_group: 191 | key = f"{interaction.data.name}:{subcommand_group.name}:{subcommand.name}:{option.name}" 192 | elif subcommand: 193 | key = f"{interaction.data.name}:{subcommand.name}:{option.name}" 194 | else: 195 | key = f"{interaction.data.name}:{option.name}" 196 | return self.autocompletes.get(key) 197 | 198 | async def handle_interaction(self, target: typing.Union[InteractionCommand, ComponentCallback, AutoComplete], interaction: InteractionContext): 199 | """ 200 | Handles received interaction. 201 | 202 | :param target: What to execute. 203 | :type target: Union[InteractionCommand, ComponentCallback, AutoComplete] 204 | :param InteractionContext interaction: Context to use. 205 | """ 206 | subcommand_group = self.__extract_subcommand_group(interaction.data.options) 207 | subcommand = self.__extract_subcommand(subcommand_group.options if subcommand_group else interaction.data.options) 208 | options = {} 209 | opts = subcommand.options if subcommand else interaction.data.options 210 | for x in opts or []: 211 | value = x.value 212 | resolved_types = [ApplicationCommandOptionType.USER, 213 | ApplicationCommandOptionType.CHANNEL, 214 | ApplicationCommandOptionType.ROLE, 215 | ApplicationCommandOptionType.MENTIONABLE] 216 | if value and int(x.type) in resolved_types: 217 | if interaction.data.resolved: 218 | value = interaction.data.resolved.get(value) 219 | elif interaction.client.has_cache: 220 | value = interaction.client.get(value) or value 221 | options[x.name] = value 222 | try: 223 | await target.invoke(interaction, options) 224 | except Exception as ex: 225 | await self.execute_error_handler(target, interaction, ex) 226 | 227 | async def execute_error_handler(self, target: typing.Union[InteractionCommand, ComponentCallback], interaction: InteractionContext, ex: Exception): 228 | """ 229 | Executes error handler. 230 | This is intended to be used internally. 231 | 232 | :param target: Target interaction object. 233 | :param interaction: Interaction context object. 234 | :param ex: Exception raised. 235 | """ 236 | if target.self_or_cls: 237 | if hasattr(target.self_or_cls, "on_addon_interaction_error") and await target.self_or_cls.on_addon_interaction_error(interaction, ex): 238 | return 239 | if hasattr(target.self_or_cls, "on_interaction_error") and await target.self_or_cls.on_interaction_error(interaction, ex): 240 | return 241 | if hasattr(interaction.client, "dispatch") and interaction.client.events.get("INTERACTION_ERROR"): 242 | interaction.client.dispatch("interaction_error", interaction, ex) 243 | else: 244 | tb = ''.join(traceback.format_exception(type(ex), ex, ex.__traceback__)) 245 | title = f"Exception while executing command {interaction.data.name}" if interaction.type.application_command else \ 246 | f"Exception while executing callback of {interaction.data.custom_id}" 247 | print(f"{title}:\n{tb}", file=sys.stderr) 248 | 249 | def wait_interaction(self, *, timeout: float = None, check: typing.Callable[[InteractionContext], bool] = None): 250 | """ 251 | Waits for interaction. Basically same as ``dico.Client.wait`` but with ``interaction`` event as default. 252 | 253 | :param timeout: When to timeout. Default ``None``, which will wait forever. 254 | :param check: Check to apply. 255 | :return: :class:`.context.InteractionContext` 256 | :raises asyncio.TimeoutError: Timed out. 257 | """ 258 | if not self.client: 259 | raise AttributeError("you cannot use wait_interaction if you didn't pass client to parameter.") 260 | return self.client.wait("interaction", timeout=timeout, check=check) 261 | 262 | def export_commands(self) -> dict: 263 | """ 264 | Exports commands of the client as the form below. 265 | 266 | .. code-block:: python 267 | 268 | { 269 | "global": [...], 270 | "guild": { 271 | GUILD_ID_1: [...], 272 | GUILD_ID_2: [...], 273 | ... 274 | } 275 | } 276 | 277 | :return: dict 278 | """ 279 | cmds = {"global": [], "guild": {}} 280 | 281 | for cmd in self.commands.values(): 282 | if cmd.guild_ids: 283 | for guild_id in cmd.guild_ids: 284 | if cmds["guild"].get(guild_id) is None: 285 | cmds["guild"][guild_id] = [] 286 | cmds["guild"][guild_id].append(cmd.command) 287 | else: 288 | cmds["global"].append(cmd.command) 289 | 290 | subcommands = {"global": {}, "guild": {}} 291 | 292 | for p_cmd in self.subcommands.values(): 293 | for c_cmd in p_cmd.values(): 294 | if c_cmd.guild_ids: 295 | for guild_id in c_cmd.guild_ids: 296 | subcommands["guild"][guild_id] = {} 297 | 298 | for p_cmd in self.subcommand_groups.values(): 299 | for c_cmd in p_cmd.values(): 300 | for s_cmd in c_cmd.values(): 301 | if s_cmd.guild_ids is not None: 302 | for guild_id in s_cmd.guild_ids: 303 | subcommands["guild"][guild_id] = {} 304 | 305 | for p_k, p_v in self.subcommands.items(): 306 | for c_k, c_v in p_v.items(): 307 | datas = [] 308 | if c_v.guild_ids: 309 | for guild_id in c_v.guild_ids: 310 | datas.append(subcommands["guild"][guild_id]) 311 | else: 312 | datas = [subcommands["global"]] 313 | 314 | for data in datas: 315 | if data.get(p_k) is None: 316 | data[p_k] = {} 317 | data[p_k][c_k] = c_v.command 318 | 319 | for data in datas: 320 | if c_v.guild_ids: 321 | for guild_id in c_v.guild_ids: 322 | subcommands["guild"][guild_id] = data 323 | else: 324 | subcommands["global"] = data 325 | 326 | for p_k, p_v in self.subcommand_groups.items(): 327 | for c_k, c_v in p_v.items(): 328 | for s_k, s_v in c_v.items(): 329 | datas = [] 330 | if s_v.guild_ids: 331 | for guild_id in s_v.guild_ids: 332 | datas.append(subcommands["guild"][guild_id]) 333 | else: 334 | datas = [subcommands["global"]] 335 | 336 | for data in datas: 337 | if data.get(p_k) is None: 338 | data[p_k] = {} 339 | if data[p_k].get(c_k) is None: 340 | data[p_k][c_k] = {} 341 | data[p_k][c_k][s_k] = s_v.command 342 | 343 | for data in datas: 344 | if s_v.guild_ids is not None: 345 | for guild_id in s_v.guild_ids: 346 | subcommands["guild"][guild_id] = data 347 | else: 348 | subcommands["global"] = data 349 | 350 | def get_command(data): 351 | data = data.values() 352 | base_cmd = None 353 | 354 | for p_cmd in data: 355 | if isinstance(p_cmd, dict): 356 | base_c_cmd = None 357 | for c_cmd in p_cmd.values(): 358 | if base_c_cmd is None: 359 | base_c_cmd = c_cmd 360 | else: 361 | base_c_cmd.options[0].options.append(c_cmd.options[0].options[0]) 362 | 363 | if base_cmd is None: 364 | base_cmd = base_c_cmd 365 | else: 366 | base_cmd.options.append(base_c_cmd.options[0]) 367 | else: 368 | if base_cmd is None: 369 | base_cmd = p_cmd 370 | else: 371 | base_cmd.options.append(p_cmd.options[0]) 372 | return base_cmd 373 | 374 | for cmd in subcommands["global"].values(): 375 | cmds["global"].append(get_command(cmd)) 376 | 377 | for guild_id, guild_cmds in subcommands["guild"].items(): 378 | if cmds["guild"].get(guild_id) is None: 379 | cmds["guild"][guild_id] = [] 380 | for cmd in guild_cmds.values(): 381 | cmds["guild"][guild_id].append(get_command(cmd)) 382 | 383 | return cmds 384 | 385 | def add_command(self, interaction: InteractionCommand): 386 | """ 387 | Adds new interaction command to the client. 388 | 389 | :param interaction: Command to add. 390 | """ 391 | if self.guild_id_locks: 392 | interaction.guild_ids = self.guild_id_locks 393 | subcommand_group = interaction.subcommand_group 394 | subcommand = interaction.subcommand 395 | name = interaction.command.name 396 | if subcommand_group: 397 | if name not in self.subcommand_groups: 398 | self.subcommand_groups[name] = {} 399 | if subcommand_group not in self.subcommand_groups[name]: 400 | self.subcommand_groups[name][subcommand_group] = {} 401 | if subcommand in self.subcommand_groups[name][subcommand_group]: 402 | raise AlreadyExists("command", f"{subcommand_group} {subcommand} {name}") 403 | self.subcommand_groups[name][subcommand_group][subcommand] = interaction 404 | elif subcommand: 405 | if name not in self.subcommands: 406 | self.subcommands[name] = {} 407 | if subcommand in self.subcommands[name]: 408 | raise AlreadyExists("command", f"{subcommand} {name}") 409 | self.subcommands[name][subcommand] = interaction 410 | else: 411 | if name in self.commands: 412 | raise AlreadyExists("command", name) 413 | self.commands[name] = interaction 414 | 415 | def remove_command(self, interaction: InteractionCommand): 416 | """ 417 | Removes command from client. 418 | 419 | :param interaction: Command to remove. 420 | """ 421 | subcommand_group = interaction.subcommand_group 422 | subcommand = interaction.subcommand 423 | name = interaction.command.name 424 | if subcommand_group: 425 | if name in self.subcommand_groups and subcommand_group in self.subcommand_groups[name] and \ 426 | subcommand in self.subcommand_groups[name][subcommand_group]: 427 | del self.subcommand_groups[name][subcommand_group][subcommand] 428 | else: 429 | raise NotExists("command", f"{subcommand_group} {subcommand} {name}") 430 | elif subcommand: 431 | if name in self.subcommands and subcommand in self.subcommands[name]: 432 | del self.subcommands[name][subcommand] 433 | else: 434 | raise NotExists("command", f"{subcommand} {name}") 435 | else: 436 | if name in self.commands: 437 | del self.commands[name] 438 | else: 439 | raise NotExists("command", name) 440 | 441 | def add_callback(self, callback: typing.Union[ComponentCallback, ModalCallback]): 442 | """ 443 | Adds component or modal callback to the client. 444 | 445 | :param callback: Callback to add. 446 | """ 447 | tgt = self.modals if isinstance(callback, ModalCallback) else self.components 448 | if callback.custom_id in tgt: 449 | raise AlreadyExists(f"{'modal' if isinstance(callback, ModalCallback) else 'component'} callback", callback.custom_id) 450 | tgt[callback.custom_id] = callback 451 | 452 | def remove_callback(self, callback: typing.Union[ComponentCallback, ModalCallback]): 453 | """ 454 | Removes callback from client. 455 | 456 | :param callback: Callback to remove. 457 | """ 458 | tgt = self.modals if isinstance(callback, ModalCallback) else self.components 459 | if callback.custom_id in tgt: 460 | del tgt[callback.custom_id] 461 | else: 462 | raise NotExists(f"{'modal' if isinstance(callback, ModalCallback) else 'component'} callback", callback.custom_id) 463 | 464 | def add_autocomplete(self, autocomplete: AutoComplete): 465 | """ 466 | Adds autocomplete to the client. 467 | 468 | :param autocomplete: Autocomplete to add. 469 | """ 470 | if autocomplete.subcommand_group: 471 | key = f"{autocomplete.name}:{autocomplete.subcommand_group}:{autocomplete.subcommand}:{autocomplete.option}" 472 | elif autocomplete.subcommand: 473 | key = f"{autocomplete.name}:{autocomplete.subcommand}:{autocomplete.option}" 474 | else: 475 | key = f"{autocomplete.name}:{autocomplete.option}" 476 | if key in self.autocompletes: 477 | raise AlreadyExists("autocomplete", f"/{key.replace(':', ' ')}:") 478 | self.autocompletes[key] = autocomplete 479 | 480 | def remove_autocomplete(self, autocomplete: AutoComplete): 481 | """ 482 | Removes autocomplete from the client. 483 | 484 | :param autocomplete: Autocomplete to remove. 485 | """ 486 | if autocomplete.subcommand_group: 487 | key = f"{autocomplete.name}:{autocomplete.subcommand_group}:{autocomplete.subcommand}:{autocomplete.option}" 488 | elif autocomplete.subcommand: 489 | key = f"{autocomplete.name}:{autocomplete.subcommand}:{autocomplete.option}" 490 | else: 491 | key = f"{autocomplete.name}:{autocomplete.option}" 492 | if key not in self.autocompletes: 493 | raise NotExists("autocomplete", f"/{key.replace(':', ' ')}:") 494 | del self.autocompletes[key] 495 | 496 | def command(self, 497 | name: str = None, 498 | *, 499 | subcommand: str = None, 500 | subcommand_group: str = None, 501 | description: str = None, 502 | subcommand_description: str = None, 503 | subcommand_group_description: str = None, 504 | command_type: typing.Union[int, ApplicationCommandTypes] = ApplicationCommandTypes.CHAT_INPUT, 505 | options: typing.List[ApplicationCommandOption] = None, 506 | default_permission: bool = True, 507 | guild_id: Snowflake.TYPING = None, 508 | guild_ids: typing.List[Snowflake.TYPING] = None, 509 | connector: typing.Dict[str, str] = None): 510 | """ 511 | Creates and registers interaction command to the client. 512 | 513 | .. note:: 514 | You should use :meth:`.slash` or :meth:`.context_menu`. 515 | 516 | .. warning:: 517 | It is not recommended to create subcommand using options, since it won't be handled properly in the client. 518 | 519 | :param name: Name of the command. 520 | :param subcommand: Subcommand of the command. 521 | :param subcommand_group: Subcommand group of the command. 522 | :param description: Description of the command. 523 | :param subcommand_description: Description of subcommand. 524 | :param subcommand_group_description: Description of subcommand group. 525 | :param command_type: Type of command. 526 | :param options: Options of the command. 527 | :param default_permission: Whether default permission is enabled. 528 | :param guild_id: ID of the guild. This is deprecated since ``0.0.7``. 529 | :param guild_ids: List of ID of the guilds. 530 | :param connector: Option parameter connector. 531 | """ 532 | def wrap(coro): 533 | cmd = command_deco(name, 534 | subcommand=subcommand, 535 | subcommand_group=subcommand_group, 536 | description=description, 537 | subcommand_description=subcommand_description, 538 | subcommand_group_description=subcommand_group_description, 539 | command_type=command_type, 540 | options=options, 541 | default_permission=default_permission, 542 | guild_id=guild_id, 543 | guild_ids=guild_ids, 544 | connector=connector)(coro) 545 | self.add_command(cmd) 546 | return cmd 547 | return wrap 548 | 549 | def slash(self, 550 | name: str = None, 551 | *, 552 | subcommand: str = None, 553 | subcommand_group: str = None, 554 | description: str, 555 | subcommand_description: str = None, 556 | subcommand_group_description: str = None, 557 | options: typing.List[ApplicationCommandOption] = None, 558 | default_permission: bool = True, 559 | guild_id: Snowflake.TYPING = None, 560 | guild_ids: typing.List[Snowflake.TYPING] = None, 561 | connector: typing.Dict[str, str] = None): 562 | """ 563 | Creates and registers slash command to the client. 564 | 565 | Example: 566 | .. code-block:: python 567 | 568 | @interaction.slash("example") 569 | async def example_slash(ctx): 570 | ... 571 | 572 | Connector Example: 573 | .. code-block:: python 574 | 575 | { 576 | "옵션": "option", 577 | "시간": "hour" 578 | } 579 | 580 | .. warning:: 581 | It is not recommended to create subcommand using options, since it won't be handled properly in the client. 582 | 583 | :param name: Name of the command. 584 | :param subcommand: Subcommand of the command. 585 | :param subcommand_group: Subcommand group of the command. 586 | :param description: Description of the command. 587 | :param subcommand_description: Description of subcommand. 588 | :param subcommand_group_description: Description of subcommand group. 589 | :param options: Options of the command. 590 | :param default_permission: Whether default permission is enabled. 591 | :param guild_id: ID of the guild. This is deprecated since ``0.0.7``. 592 | :param guild_ids: List of ID of the guilds. 593 | :param connector: Option parameter connector. 594 | """ 595 | return self.command(name=name, 596 | subcommand=subcommand, 597 | subcommand_group=subcommand_group, 598 | description=description, 599 | subcommand_description=subcommand_description, 600 | subcommand_group_description=subcommand_group_description, 601 | options=options, 602 | default_permission=default_permission, 603 | guild_id=guild_id, 604 | guild_ids=guild_ids, 605 | connector=connector) 606 | 607 | def context_menu(self, 608 | name: str = None, 609 | menu_type: typing.Union[int, ApplicationCommandTypes] = ApplicationCommandTypes.MESSAGE, 610 | guild_id: Snowflake.TYPING = None, 611 | guild_ids: typing.List[Snowflake.TYPING] = None): 612 | """ 613 | Creates and registers context menu to the client. 614 | 615 | :param name: Name of the command. 616 | :param menu_type: Type of the context menu. 617 | :param guild_id: ID of the guild. This is deprecated since ``0.0.7``. 618 | :param guild_ids: List of ID of the guilds. 619 | """ 620 | if int(menu_type) == ApplicationCommandTypes.CHAT_INPUT: 621 | raise TypeError("unsupported context menu type for context_menu decorator.") 622 | return self.command(name=name, description="", command_type=menu_type, guild_id=guild_id, guild_ids=guild_ids) 623 | 624 | def component_callback(self, custom_id: str = None): 625 | """ 626 | Adds component callback to the client. 627 | 628 | :param custom_id: Custom ID of the component. Can be prefix of the custom ID. 629 | """ 630 | def wrap(coro): 631 | callback = ComponentCallback(custom_id, coro) 632 | self.add_callback(callback) 633 | return callback 634 | return wrap 635 | 636 | def autocomplete(self, *names: str, name: str = None, subcommand_group: str = None, subcommand: str = None, option: str = None): 637 | """ 638 | Adds autocomplete to the client. Supports two style: 639 | 640 | .. code-block: python 641 | 642 | @interaction.autocomplete("example", "option") 643 | async def ...(ctx: InteractionContext): 644 | await ctx.send(choices=[...]) 645 | 646 | @interaction.autocomplete(name="example", option="option") 647 | async def ...(ctx: InteractionContext): 648 | await ctx.send(choices=[...]) 649 | 650 | :param name: Name of the command that has autocomplete option. 651 | :param subcommand_group: Subcommand group of the command. 652 | :param subcommand: Subcommand of the command. 653 | :param option: Name of the option with autocomplete enabled. 654 | """ 655 | def wrap(coro): 656 | autocomplete = autocomplete_deco(*names, name=name, subcommand_group=subcommand_group, subcommand=subcommand, option=option)(coro) 657 | self.add_autocomplete(autocomplete) 658 | return autocomplete 659 | return wrap 660 | 661 | def modal(self, custom_id: str = None): 662 | """ 663 | Adds modal callback to the client. 664 | 665 | :param custom_id: Custom ID of the modal. Can be prefix of the custom ID. 666 | """ 667 | def wrap(coro): 668 | callback = ModalCallback(custom_id, coro) 669 | self.add_callback(callback) 670 | return callback 671 | return wrap 672 | -------------------------------------------------------------------------------- /dico_interaction/command.py: -------------------------------------------------------------------------------- 1 | import typing 2 | import warnings 3 | 4 | from dico import ApplicationCommand, Snowflake, ApplicationCommandOption 5 | 6 | from .context import InteractionContext 7 | from .exception import InvalidOptionParameter, CheckFailed 8 | from .utils import read_function, to_option_type, is_coro 9 | 10 | 11 | class InteractionCommand: 12 | def __init__(self, 13 | coro, 14 | command: ApplicationCommand, 15 | guild_id: Snowflake = None, 16 | guild_ids: typing.List[Snowflake] = None, 17 | subcommand: str = None, 18 | subcommand_group: str = None, 19 | checks: typing.Optional[typing.List[typing.Callable[[InteractionContext], typing.Union[bool, typing.Awaitable[bool]]]]] = None, 20 | connector: dict = None): 21 | self.coro = coro 22 | self.command = command 23 | if guild_id and guild_ids: 24 | raise ValueError("You can't set both guild_id and guild_ids") 25 | elif guild_id: 26 | self.guild_ids = [guild_id] 27 | self.guild_ids = guild_ids 28 | self.subcommand = subcommand 29 | self.subcommand_group = subcommand_group 30 | self.checks = checks or [] 31 | self.connector = connector or {} 32 | 33 | if hasattr(self.coro, "_extra_options"): 34 | self.add_options(*reversed(self.coro._extra_options)) 35 | if hasattr(self.coro, "_checks"): 36 | self.checks.extend(self.coro._checks) 37 | 38 | opts = self.__command_option 39 | param_data = read_function(self.coro) 40 | self.__options_from_args = param_data and not opts 41 | if self.__options_from_args: 42 | for k, v in param_data.items(): 43 | try: 44 | opt = ApplicationCommandOption(option_type=to_option_type(v["annotation"]), 45 | name=k, 46 | description="No description.", 47 | required=v["required"]) 48 | opts.append(opt) 49 | except NotImplementedError: 50 | raise TypeError("unsupported type detected, in this case please manually pass options param to command decorator.") from None 51 | self.__command_option = opts 52 | self.self_or_cls = None 53 | self.autocompletes = [] 54 | 55 | @property 56 | def guild_id(self): 57 | warnings.warn("guild_id is deprecated, use guild_ids instead", DeprecationWarning, stacklevel=2) 58 | return self.guild_ids[0] if self.guild_ids else None 59 | 60 | def register_self_or_cls(self, addon): 61 | self.self_or_cls = addon 62 | 63 | async def evaluate_checks(self, interaction: InteractionContext): 64 | if self.self_or_cls and hasattr(self.self_or_cls, "addon_interaction_check") and not await self.self_or_cls.addon_interaction_check(interaction): 65 | return False 66 | resp = [n for n in [(await x(interaction)) if is_coro(x) else x(interaction) for x in self.checks] if not n] 67 | return not resp 68 | 69 | async def invoke(self, interaction, options: dict): 70 | if not await self.evaluate_checks(interaction): 71 | raise CheckFailed 72 | param_data = read_function(self.coro) 73 | interaction.options = options 74 | options = {self.connector.get(k, k): v for k, v in options.items()} 75 | required_options = {k: v for k, v in param_data.items() if v["required"]} 76 | missing_options = [x for x in required_options if x not in options] 77 | missing_params = [x for x in options if x not in param_data] 78 | if missing_options or missing_params: 79 | raise InvalidOptionParameter 80 | args = (interaction,) if self.self_or_cls is None else (self.self_or_cls, interaction) 81 | return await self.coro(*args, **options) 82 | 83 | def add_options(self, *options: ApplicationCommandOption): 84 | if hasattr(self, "__options_from_args") and self.__options_from_args: 85 | self.__command_option = [] 86 | self.__options_from_args = False 87 | self.__command_option.extend(options) 88 | 89 | def autocomplete(self, option: str): 90 | raise NotImplementedError 91 | def wrap(coro): 92 | resp = autocomplete(name=self.command.name, subcommand=self.subcommand, subcommand_group=self.subcommand_group, option=option)(coro) 93 | self.autocompletes.append(resp) 94 | return resp 95 | return wrap 96 | 97 | @property 98 | def __command_option(self): 99 | if self.subcommand_group: 100 | return self.command.options[0].options[0].options 101 | elif self.subcommand: 102 | return self.command.options[0].options 103 | else: 104 | return self.command.options 105 | 106 | @__command_option.setter 107 | def __command_option(self, value): 108 | if self.subcommand_group: 109 | self.command.options[0].options[0].options = value 110 | elif self.subcommand: 111 | self.command.options[0].options = value 112 | else: 113 | self.command.options = value 114 | 115 | 116 | class AutoComplete: 117 | def __init__(self, coro, name: str, subcommand_group: str, subcommand: str, option: str): 118 | self.coro = coro 119 | self.name = name 120 | self.subcommand_group = subcommand_group 121 | self.subcommand = subcommand 122 | self.option = option 123 | self.self_or_cls = None 124 | 125 | def register_self_or_cls(self, addon): 126 | self.self_or_cls = addon 127 | 128 | def invoke(self, interaction, options: dict): 129 | args = (interaction,) if self.self_or_cls is None else (self.self_or_cls, interaction) 130 | return self.coro(*args) 131 | 132 | 133 | def autocomplete(*names: str, name: str = None, subcommand_group: str = None, subcommand: str = None, option: str = None): 134 | if names: 135 | if not name: 136 | name = names[0] 137 | if not option: 138 | option = names[-1] 139 | if len(names) == 3 and not subcommand: 140 | subcommand = names[1] 141 | elif len(names) == 4: 142 | if not subcommand_group: 143 | subcommand_group = names[1] 144 | if not subcommand: 145 | subcommand = names[2] 146 | 147 | def wrap(coro): 148 | return AutoComplete(coro, name, subcommand_group, subcommand, option) 149 | return wrap 150 | -------------------------------------------------------------------------------- /dico_interaction/component.py: -------------------------------------------------------------------------------- 1 | class ComponentCallback: 2 | # This is kinda temporary 3 | 4 | def __init__(self, custom_id, coro): 5 | self.custom_id = custom_id or coro.__name__ 6 | self.coro = coro 7 | self.self_or_cls = None 8 | 9 | def register_self_or_cls(self, addon): 10 | self.self_or_cls = addon 11 | 12 | def invoke(self, interaction, *_, **__): 13 | args = (interaction,) if self.self_or_cls is None else (self.self_or_cls, interaction) 14 | return self.coro(*args) 15 | -------------------------------------------------------------------------------- /dico_interaction/context.py: -------------------------------------------------------------------------------- 1 | import io 2 | import typing 3 | import asyncio 4 | import pathlib 5 | 6 | from dico import Interaction, InteractionResponse, InteractionCallbackType, InteractionApplicationCommandCallbackData, Embed, AllowedMentions, Component, ApplicationCommandOptionChoice 7 | 8 | 9 | class InteractionContext(Interaction): 10 | def __init__(self, client, resp): 11 | super().__init__(client, resp) 12 | self.respond_via_endpoint = resp.get("respond_via_endpoint", True) 13 | self.response = asyncio.Future() 14 | self.deferred = False 15 | self.logger = resp["logger"] 16 | self.options = {} 17 | 18 | def defer(self, ephemeral: bool = False, update_message: bool = False): 19 | if self.type.application_command or self.type.modal_submit: 20 | if update_message: 21 | self.logger.warning("update_message is only for message component. Ignoring update_message param.") 22 | callback_type = InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE 23 | elif self.type.message_component: 24 | callback_type = InteractionCallbackType.DEFERRED_UPDATE_MESSAGE if update_message else InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE 25 | else: 26 | raise NotImplementedError 27 | resp = InteractionResponse(callback_type, InteractionApplicationCommandCallbackData(flags=64 if ephemeral else None)) 28 | self.deferred = True 29 | return self.create_response(resp) 30 | 31 | def send(self, 32 | content: str = None, 33 | *, 34 | username: str = None, 35 | avatar_url: str = None, 36 | tts: bool = False, 37 | file: typing.Union[io.FileIO, pathlib.Path, str] = None, 38 | files: typing.List[typing.Union[io.FileIO, pathlib.Path, str]] = None, 39 | embed: typing.Union[Embed, dict] = None, 40 | embeds: typing.List[typing.Union[Embed, dict]] = None, 41 | allowed_mentions: typing.Union[AllowedMentions, dict] = None, 42 | components: typing.List[typing.Union[dict, Component]] = None, 43 | choices: typing.Optional[typing.List[typing.Union[dict, ApplicationCommandOptionChoice]]] = None, 44 | custom_id: str = None, 45 | title: str = None, 46 | ephemeral: bool = False, 47 | update_message: bool = False): 48 | if self.type.application_command: 49 | if update_message: 50 | self.logger.warning("update_message is only for message component. Ignoring update_message param.") 51 | if not self.deferred: 52 | if embed and embeds: 53 | raise TypeError("you can't pass both embed and embeds.") 54 | if embed: 55 | embeds = [embed] 56 | if file or files: 57 | self.logger.warning("file and files are not supported on initial response. Ignoring file or files param.") 58 | callback_type = InteractionCallbackType.APPLICATION_COMMAND_AUTOCOMPLETE_RESULT if self.type.application_command_autocomplete else \ 59 | InteractionCallbackType.UPDATE_MESSAGE if update_message else InteractionCallbackType.MODAL if custom_id else InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE 60 | data = InteractionApplicationCommandCallbackData(tts=tts, 61 | content=content, 62 | embeds=embeds, 63 | allowed_mentions=allowed_mentions, 64 | flags=64 if ephemeral else None, 65 | components=components, 66 | choices=choices, 67 | custom_id=custom_id, 68 | title=title) 69 | resp = InteractionResponse(callback_type, data) 70 | self.deferred = True 71 | return self.create_response(resp) 72 | params = {"content": content, 73 | "username": username, 74 | "avatar_url": avatar_url, 75 | "tts": tts, 76 | "file": file, 77 | "files": files, 78 | "embed": embed, 79 | "embeds": embeds, 80 | "allowed_mentions": allowed_mentions, 81 | "components": components, 82 | "ephemeral": ephemeral} 83 | return self.create_followup_message(**params) 84 | 85 | async def create_response(self, interaction_response: InteractionResponse): 86 | if not self.respond_via_endpoint: 87 | self.response.set_result(interaction_response) 88 | else: 89 | return await super().create_response(interaction_response) 90 | 91 | def get_value(self, custom_id: str): 92 | if not self.type.modal_submit: 93 | raise AttributeError("this is only allowed for modal submit") 94 | comps = [] 95 | for x in self.data.components: 96 | comps.extend(x.components) 97 | resp = [y for y in comps if y.custom_id == custom_id] 98 | if resp: 99 | return resp[0].value 100 | raise KeyError(custom_id) 101 | 102 | @classmethod 103 | def from_interaction(cls, interaction: Interaction, logger): 104 | resp = interaction.raw 105 | resp["logger"] = logger 106 | return cls(interaction.client, resp) 107 | -------------------------------------------------------------------------------- /dico_interaction/deco.py: -------------------------------------------------------------------------------- 1 | import typing 2 | import warnings 3 | 4 | from dico import ( 5 | ApplicationCommandTypes, ApplicationCommandOption, ApplicationCommandOptionType, Snowflake, ApplicationCommand, ApplicationCommandOptionChoice, ChannelTypes 6 | ) 7 | from .command import InteractionCommand 8 | from .context import InteractionContext 9 | from .component import ComponentCallback 10 | from .modal import ModalCallback 11 | 12 | 13 | def command(name: str = None, 14 | *, 15 | subcommand: str = None, 16 | subcommand_group: str = None, 17 | description: str = None, 18 | subcommand_description: str = None, 19 | subcommand_group_description: str = None, 20 | command_type: typing.Union[int, ApplicationCommandTypes] = ApplicationCommandTypes.CHAT_INPUT, 21 | options: typing.List[ApplicationCommandOption] = None, 22 | default_permission: bool = True, 23 | guild_id: Snowflake.TYPING = None, 24 | guild_ids: typing.List[Snowflake.TYPING] = None, 25 | connector: typing.Dict[str, str] = None): 26 | if int(command_type) == ApplicationCommandTypes.CHAT_INPUT and not description: 27 | raise ValueError("description must be passed if type is CHAT_INPUT.") 28 | if guild_id and guild_ids: 29 | raise ValueError("guild_id and guild_ids cannot be both passed.") 30 | elif guild_id: 31 | warnings.warn("guild_id is deprecated, use guild_ids instead.", DeprecationWarning, stacklevel=2) 32 | guild_ids = [guild_id] 33 | description = description or "" 34 | options = options or [] 35 | if subcommand: 36 | if int(command_type) != ApplicationCommandTypes.CHAT_INPUT: 37 | raise TypeError("subcommand is exclusive to CHAT_INPUT.") 38 | if not subcommand_description: 39 | raise ValueError("subcommand_description must be passed if subcommand is set.") 40 | options = [ApplicationCommandOption(option_type=ApplicationCommandOptionType.SUB_COMMAND, 41 | name=subcommand, 42 | description=subcommand_description, 43 | options=options.copy())] 44 | if subcommand_group: 45 | if int(command_type) != ApplicationCommandTypes.CHAT_INPUT: 46 | raise TypeError("subcommand_group is exclusive to CHAT_INPUT.") 47 | if not subcommand: 48 | raise ValueError("subcommand must be passed if subcommand_group is set.") 49 | if not subcommand_group_description: 50 | raise ValueError("subcommand_group_description must be passed if subcommand_group is set.") 51 | options = options.copy() 52 | options = [ApplicationCommandOption(option_type=ApplicationCommandOptionType.SUB_COMMAND_GROUP, 53 | name=subcommand_group, 54 | description=subcommand_group_description, 55 | options=options.copy())] 56 | 57 | def wrap(coro): 58 | _command = ApplicationCommand(name=name or coro.__name__, description=description, command_type=command_type, options=options, default_permission=default_permission) 59 | cmd = InteractionCommand(coro=coro, command=_command, guild_ids=guild_ids, subcommand=subcommand, subcommand_group=subcommand_group, connector=connector) 60 | return cmd 61 | 62 | return wrap 63 | 64 | 65 | def slash(name: str = None, 66 | *, 67 | subcommand: str = None, 68 | subcommand_group: str = None, 69 | description: str, 70 | subcommand_description: str = None, 71 | subcommand_group_description: str = None, 72 | options: typing.List[ApplicationCommandOption] = None, 73 | default_permission: bool = True, 74 | guild_id: Snowflake.TYPING = None, 75 | guild_ids: typing.List[Snowflake.TYPING] = None, 76 | connector: typing.Dict[str, str] = None): 77 | return command(name=name, 78 | subcommand=subcommand, 79 | subcommand_group=subcommand_group, 80 | description=description, 81 | subcommand_description=subcommand_description, 82 | subcommand_group_description=subcommand_group_description, 83 | options=options, 84 | default_permission=default_permission, 85 | guild_id=guild_id, 86 | guild_ids=guild_ids, 87 | connector=connector) 88 | 89 | 90 | def context_menu(name: str = None, 91 | menu_type: typing.Union[int, ApplicationCommandTypes] = ApplicationCommandTypes.MESSAGE, 92 | guild_id: Snowflake.TYPING = None, 93 | guild_ids: typing.List[Snowflake.TYPING] = None): 94 | if int(menu_type) == ApplicationCommandTypes.CHAT_INPUT: 95 | raise TypeError("unsupported context menu type for context_menu decorator.") 96 | return command(name=name, description="", command_type=menu_type, guild_id=guild_id, guild_ids=guild_ids) 97 | 98 | 99 | def option(option_type: typing.Union[ApplicationCommandOptionType, int], 100 | *, 101 | name: str, 102 | description: str, 103 | required: typing.Optional[bool] = None, 104 | choices: typing.Optional[typing.List[ApplicationCommandOptionChoice]] = None, 105 | autocomplete: typing.Optional[bool] = None, 106 | options: typing.Optional[typing.List[ApplicationCommandOption]] = None, 107 | channel_types: typing.Optional[typing.List[typing.Union[int, ChannelTypes]]] = None, 108 | min_value: typing.Optional[typing.Union[int, float]] = None, 109 | max_value: typing.Optional[typing.Union[int, float]] = None, 110 | min_length: typing.Optional[int] = None, 111 | max_length: typing.Optional[int] = None,): 112 | if int(option_type) == ApplicationCommandOptionType.SUB_COMMAND_GROUP and choices: 113 | raise TypeError("choices is not allowed if option type is SUB_COMMAND_GROUP.") 114 | if int(option_type) == ApplicationCommandOptionType.SUB_COMMAND_GROUP and not options: 115 | raise TypeError("you must pass options if option type is SUB_COMMAND_GROUP.") 116 | option_to_add = ApplicationCommandOption(option_type=option_type, 117 | name=name, 118 | description=description, 119 | required=required, 120 | choices=choices, 121 | autocomplete=autocomplete, 122 | options=options, 123 | channel_types=channel_types, 124 | min_value=min_value, 125 | max_value=max_value, 126 | min_length=min_length, 127 | max_length=max_length) 128 | 129 | def wrap(maybe_cmd): 130 | if isinstance(maybe_cmd, InteractionCommand): 131 | maybe_cmd.add_options(option_to_add) 132 | else: 133 | if not hasattr(maybe_cmd, "_extra_options"): 134 | maybe_cmd._extra_options = [] 135 | maybe_cmd._extra_options.append(option_to_add) 136 | return maybe_cmd 137 | 138 | return wrap 139 | 140 | 141 | def checks(*funcs: typing.Callable[[InteractionContext], typing.Union[bool, typing.Awaitable[bool]]]): 142 | def wrap(maybe_cmd): 143 | if isinstance(maybe_cmd, InteractionCommand): 144 | maybe_cmd.checks.extend(funcs) 145 | else: 146 | if hasattr(maybe_cmd, "_checks"): 147 | maybe_cmd._checks.extend(funcs) 148 | else: 149 | maybe_cmd._checks = [*funcs] 150 | return maybe_cmd 151 | return wrap 152 | 153 | 154 | def component_callback(custom_id: str = None): 155 | def wrap(coro): 156 | return ComponentCallback(custom_id, coro) 157 | return wrap 158 | 159 | 160 | def modal_callback(custom_id: str = None): 161 | def wrap(coro): 162 | return ModalCallback(custom_id, coro) 163 | return wrap 164 | -------------------------------------------------------------------------------- /dico_interaction/exception.py: -------------------------------------------------------------------------------- 1 | class InteractionException(Exception): 2 | """Base exception of this module.""" 3 | 4 | 5 | class InvalidOptionParameter(InteractionException): 6 | """Received option does not match to command parameter.""" 7 | 8 | 9 | class CheckFailed(InteractionException): 10 | """Check has failed.""" 11 | 12 | 13 | class AlreadyExists(InteractionException): 14 | """This command or callback already exists.""" 15 | def __init__(self, error_type, name): 16 | super().__init__(f"{error_type} '{name}' already exists") 17 | 18 | 19 | class NotExists(InteractionException): 20 | """This command or callback does not exist.""" 21 | def __init__(self, error_type, name): 22 | super().__init__(f"{error_type} '{name}' does not exist") 23 | -------------------------------------------------------------------------------- /dico_interaction/modal.py: -------------------------------------------------------------------------------- 1 | from .component import ComponentCallback 2 | 3 | 4 | class ModalCallback(ComponentCallback): 5 | pass 6 | -------------------------------------------------------------------------------- /dico_interaction/utils.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from dico import GuildMember, User, Channel, Role, ApplicationCommandOptionType 3 | 4 | 5 | def is_coro(coro): 6 | return inspect.iscoroutinefunction(coro) or inspect.isawaitable(coro) or inspect.iscoroutine(coro) 7 | 8 | 9 | def read_function(func): 10 | params = [*inspect.signature(func).parameters.values()] 11 | if params[0].name in ["self", "cls"]: 12 | del params[0] # Skip self or cls 13 | del params[0] # skip InteractionContext 14 | ret = {} 15 | for x in params: 16 | ret[x.name] = { 17 | "required": x.default == inspect._empty, 18 | "default": x.default, 19 | "annotation": x.annotation, 20 | "kind": x.kind 21 | } 22 | return ret 23 | 24 | 25 | def to_option_type(annotation): 26 | if annotation is str: 27 | return ApplicationCommandOptionType.STRING 28 | elif annotation is int: 29 | return ApplicationCommandOptionType.INTEGER 30 | elif annotation is bool: 31 | return ApplicationCommandOptionType.BOOLEAN 32 | elif annotation is User or annotation is GuildMember: 33 | return ApplicationCommandOptionType.USER 34 | elif annotation is Channel: 35 | return ApplicationCommandOptionType.CHANNEL 36 | elif annotation is Role: 37 | return ApplicationCommandOptionType.ROLE 38 | elif annotation is float: 39 | return ApplicationCommandOptionType.NUMBER 40 | else: 41 | raise NotImplementedError 42 | -------------------------------------------------------------------------------- /dico_interaction/webserver.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import typing 3 | 4 | from aiohttp import web 5 | 6 | import dico 7 | from dico.api import APIClient 8 | from dico.http.async_http import AsyncHTTPRequest 9 | 10 | try: 11 | from nacl.signing import VerifyKey 12 | from nacl.exceptions import BadSignatureError 13 | except ImportError: 14 | import sys 15 | print("PyNaCl not installed, webserver won't be available.", file=sys.stderr) 16 | VerifyKey = lambda _: _ 17 | BadSignatureError = Exception 18 | 19 | from .client import InteractionClient 20 | from .context import InteractionContext 21 | 22 | 23 | class InteractionWebserver: 24 | def __init__(self, 25 | token: str, 26 | public_key: str, 27 | interaction: InteractionClient, 28 | loop: asyncio.AbstractEventLoop = None, 29 | allowed_mentions: dico.AllowedMentions = None, 30 | application_id: typing.Union[int, str, dico.Snowflake] = None): 31 | self.loop = loop or interaction.loop or asyncio.get_event_loop() 32 | self.dico_api = APIClient(token, base=AsyncHTTPRequest, loop=self.loop, default_allowed_mentions=allowed_mentions, application_id=application_id) 33 | self.interaction = interaction 34 | self.__verify_key = VerifyKey(bytes.fromhex(public_key)) 35 | self.webserver = web.Application(loop=self.loop, middlewares=[self.verify_security]) 36 | self.webserver.router.add_post("/", self.receive_interaction) 37 | 38 | async def receive_interaction(self, request: web.Request): 39 | body = await request.json() 40 | if body["type"] == 1: 41 | return web.json_response({"type": 1}) 42 | payload = body 43 | payload["respond_via_endpoint"] = False 44 | payload["logger"] = self.interaction.logger 45 | interaction = InteractionContext.create(self.dico_api, payload) 46 | return web.json_response(await self.interaction.receive(interaction)) # This returns initial response. 47 | 48 | @web.middleware 49 | async def verify_security(self, request: web.Request, handler): 50 | if request.method != "POST": 51 | return await handler(request) 52 | try: 53 | sign = request.headers["X-Signature-Ed25519"] 54 | message = request.headers["X-Signature-Timestamp"].encode() + await request.read() 55 | self.__verify_key.verify(message, bytes.fromhex(sign)) 56 | return await handler(request) 57 | except (BadSignatureError, KeyError): 58 | return web.Response(text="Invalid Signature", status=401) 59 | 60 | async def start(self, *args, **kwargs): 61 | self.runner = web.AppRunner(self.webserver) 62 | await self.runner.setup() 63 | site = web.TCPSite(self.runner, *args, **kwargs) 64 | await site.start() 65 | 66 | async def close(self): 67 | await self.dico_api.http.close() 68 | await self.runner.cleanup() 69 | 70 | def run(self, *args, **kwargs): 71 | try: 72 | self.loop.create_task(self.start(*args, **kwargs)) 73 | self.loop.run_forever() 74 | except KeyboardInterrupt: 75 | pass 76 | finally: 77 | self.loop.run_until_complete(self.close()) 78 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | API Document 2 | ============ 3 | 4 | .. toctree:: 5 | :glob: 6 | 7 | api/* -------------------------------------------------------------------------------- /docs/api/dico_interaction.client.rst: -------------------------------------------------------------------------------- 1 | dico\_interaction.client module 2 | =============================== 3 | 4 | .. automodule:: dico_interaction.client 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/api/dico_interaction.command.rst: -------------------------------------------------------------------------------- 1 | dico\_interaction.command module 2 | ================================ 3 | 4 | .. automodule:: dico_interaction.command 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/api/dico_interaction.component.rst: -------------------------------------------------------------------------------- 1 | dico\_interaction.component module 2 | ================================== 3 | 4 | .. automodule:: dico_interaction.component 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/api/dico_interaction.context.rst: -------------------------------------------------------------------------------- 1 | dico\_interaction.context module 2 | ================================ 3 | 4 | .. automodule:: dico_interaction.context 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/api/dico_interaction.deco.rst: -------------------------------------------------------------------------------- 1 | dico\_interaction.deco module 2 | ============================= 3 | 4 | .. automodule:: dico_interaction.deco 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/api/dico_interaction.exception.rst: -------------------------------------------------------------------------------- 1 | dico\_interaction.exception module 2 | ================================== 3 | 4 | .. automodule:: dico_interaction.exception 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/api/dico_interaction.rst: -------------------------------------------------------------------------------- 1 | dico\_interaction package 2 | ========================= 3 | 4 | Submodules 5 | ---------- 6 | 7 | .. toctree:: 8 | :maxdepth: 4 9 | 10 | dico_interaction.client 11 | dico_interaction.command 12 | dico_interaction.component 13 | dico_interaction.context 14 | dico_interaction.deco 15 | dico_interaction.exception 16 | dico_interaction.utils 17 | dico_interaction.webserver 18 | 19 | Module contents 20 | --------------- 21 | 22 | .. automodule:: dico_interaction 23 | :members: 24 | :undoc-members: 25 | :show-inheritance: 26 | -------------------------------------------------------------------------------- /docs/api/dico_interaction.utils.rst: -------------------------------------------------------------------------------- 1 | dico\_interaction.utils module 2 | ============================== 3 | 4 | .. automodule:: dico_interaction.utils 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/api/dico_interaction.webserver.rst: -------------------------------------------------------------------------------- 1 | dico\_interaction.webserver module 2 | ================================== 3 | 4 | .. automodule:: dico_interaction.webserver 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/api/modules.rst: -------------------------------------------------------------------------------- 1 | dico_interaction 2 | ================ 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | dico_interaction 8 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | import sphinx_rtd_theme 16 | sys.path.insert(0, os.path.abspath('..')) 17 | 18 | 19 | # -- Project information ----------------------------------------------------- 20 | 21 | project = 'dico-interaction' 22 | copyright = '2021, dico-api' 23 | author = 'dico-api' 24 | 25 | 26 | # -- General configuration --------------------------------------------------- 27 | 28 | # Add any Sphinx extension module names here, as strings. They can be 29 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 30 | # ones. 31 | extensions = [ 32 | "sphinx.ext.autodoc", 33 | "sphinx.ext.intersphinx", 34 | "sphinx_rtd_theme" 35 | ] 36 | 37 | # Add any paths that contain templates here, relative to this directory. 38 | templates_path = ['_templates'] 39 | 40 | # List of patterns, relative to source directory, that match files and 41 | # directories to ignore when looking for source files. 42 | # This pattern also affects html_static_path and html_extra_path. 43 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store', "test", ".idea", "dico", "api/dico_interaction.rst", "api/modules.rst"] 44 | 45 | 46 | 47 | # -- Options for HTML output ------------------------------------------------- 48 | 49 | # The theme to use for HTML and HTML Help pages. See the documentation for 50 | # a list of builtin themes. 51 | # 52 | html_theme = "sphinx_rtd_theme" 53 | 54 | # This should fix wrong sort 55 | autodoc_member_order = 'bysource' 56 | 57 | # Add any paths that contain custom static files (such as style sheets) here, 58 | # relative to this directory. They are copied after the builtin static files, 59 | # so a file named "default.css" will overwrite the builtin "default.css". 60 | html_static_path = ['_static'] 61 | 62 | # Intersphinx 63 | intersphinx_mapping = { 64 | 'py': ('https://docs.python.org/3', None), 65 | 'dico': ('https://dico.readthedocs.io/en/latest/', None) 66 | } -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. dico-interaction documentation master file, created by 2 | sphinx-quickstart on Sat Aug 28 20:45:48 2021. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to dico-interaction's documentation! 7 | ============================================ 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :caption: Contents: 12 | 13 | api.rst 14 | 15 | 16 | 17 | Indices and tables 18 | ================== 19 | 20 | * :ref:`genindex` 21 | * :ref:`modindex` 22 | * :ref:`search` 23 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Build documentation in the docs/ directory with Sphinx 9 | sphinx: 10 | configuration: docs/conf.py 11 | 12 | # Build documentation with MkDocs 13 | #mkdocs: 14 | # configuration: mkdocs.yml 15 | 16 | # Optionally build your docs in additional formats such as PDF 17 | formats: 18 | - pdf 19 | 20 | # Optionally set the version of Python and requirements required to build your docs 21 | python: 22 | version: 3.7 23 | install: 24 | - requirements: requirements.txt -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | dico-api 2 | aiohttp 3 | PyNaCl 4 | sphinx 5 | sphinx-rtd-theme -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r", encoding="UTF-8") as f: 4 | long_description = f.read() 5 | 6 | setuptools.setup( 7 | name="dico-interaction", 8 | version="0.0.9", 9 | author="eunwoo1104", 10 | author_email="sions04@naver.com", 11 | description="Interaction module for dico.", 12 | long_description=long_description, 13 | long_description_content_type="text/markdown", 14 | url="https://github.com/dico-api/dico-interaction", 15 | packages=setuptools.find_packages(), 16 | python_requires='>=3.7', 17 | install_requires=["dico-api", "aiohttp"], 18 | extras_require={ 19 | "webserver": ["PyNaCl"] 20 | }, 21 | classifiers=[ 22 | "Programming Language :: Python :: 3" 23 | ] 24 | ) 25 | --------------------------------------------------------------------------------