From acdc1df084a83abedf55cf1558c739e686902741 Mon Sep 17 00:00:00 2001 From: Kowlin <10947836+Kowlin@users.noreply.github.com> Date: Fri, 20 May 2022 21:58:18 +0200 Subject: [PATCH] Add support for set api Modals (#5637) * Add support for set api Modals Co-authored-by: TrustyJAID * Blaacckkkk! * Swap locations of interaction and button. * Clarified template tokens * Update docs and some string * More docs * Rework the client Co-authored-by: TrustyJAID * Goddamned black! * Missed a few arguments * Black... Again * Update redbot/core/utils/views.py Co-authored-by: TrustyJAID * Update redbot/core/core_commands.py Co-authored-by: TrustyJAID Co-authored-by: TrustyJAID --- docs/framework_utils.rst | 6 + redbot/core/commands/converter.py | 4 +- redbot/core/core_commands.py | 29 ++++- redbot/core/utils/views.py | 176 ++++++++++++++++++++++++++++++ 4 files changed, 208 insertions(+), 7 deletions(-) create mode 100644 redbot/core/utils/views.py diff --git a/docs/framework_utils.rst b/docs/framework_utils.rst index 8ed4d9225..3ec698114 100644 --- a/docs/framework_utils.rst +++ b/docs/framework_utils.rst @@ -73,3 +73,9 @@ Common Filters .. automodule:: redbot.core.utils.common_filters :members: + +Utility UI +========== + +.. automodule:: redbot.core.utils.views + :members: diff --git a/redbot/core/commands/converter.py b/redbot/core/commands/converter.py index 16f986d5b..3f8cc74d0 100644 --- a/redbot/core/commands/converter.py +++ b/redbot/core/commands/converter.py @@ -254,13 +254,13 @@ else: args = self.pattern.split(argument) if len(args) % 2 != 0: - raise BadArgument() + raise BadArgument(_("Missing a key or value.")) iterator = iter(args) for key in iterator: if self.expected_keys and key not in self.expected_keys: - raise BadArgument(_("Unexpected key {key}").format(key=key)) + raise BadArgument(_("Unexpected key `{key}`.").format(key=key)) ret[key] = next(iterator) diff --git a/redbot/core/core_commands.py b/redbot/core/core_commands.py index 0402c2f35..8a65811c9 100644 --- a/redbot/core/core_commands.py +++ b/redbot/core/core_commands.py @@ -19,6 +19,7 @@ import traceback from pathlib import Path from redbot.core import data_manager from redbot.core.utils.menus import menu +from redbot.core.utils.views import SetApiView from redbot.core.commands import GuildConverter, RawUserIdConverter from string import ascii_letters, digits from typing import TYPE_CHECKING, Union, Tuple, List, Optional, Iterable, Sequence, Dict, Set @@ -3091,18 +3092,28 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): @_set.group(name="api", invoke_without_command=True) @checks.is_owner() - async def _set_api(self, ctx: commands.Context, service: str, *, tokens: TokenConverter): + async def _set_api( + self, + ctx: commands.Context, + service: Optional[str] = None, + *, + tokens: Optional[TokenConverter] = None, + ): """ Commands to set, list or remove various external API tokens. This setting will be asked for by some 3rd party cogs and some core cogs. + If passed without the `` or `` arguments it will allow you to open a modal to set your API keys securely. + To add the keys provide the service name and the tokens as a comma separated list of key,values as described by the cog requesting this command. Note: API tokens are sensitive, so this command should only be used in a private channel or in DM with the bot. **Examples:** + - `[p]set api` + - `[p]set api spotify - `[p]set api spotify redirect_uri localhost` - `[p]set api github client_id,whoops client_secret,whoops` @@ -3110,10 +3121,18 @@ class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic): - `` - The service you're adding tokens to. - `` - Pairs of token keys and values. The key and value should be separated by one of ` `, `,`, or `;`. """ - if ctx.channel.permissions_for(ctx.me).manage_messages: - await ctx.message.delete() - await ctx.bot.set_shared_api_tokens(service, **tokens) - await ctx.send(_("`{service}` API tokens have been set.").format(service=service)) + if service is None: # Handled in order of missing operations + await ctx.send(_("Click the button below to set your keys."), view=SetApiView()) + elif tokens is None: + await ctx.send( + _("Click the button below to set your keys."), + view=SetApiView(default_service=service), + ) + else: + if ctx.channel.permissions_for(ctx.me).manage_messages: + await ctx.message.delete() + await ctx.bot.set_shared_api_tokens(service, **tokens) + await ctx.send(_("`{service}` API tokens have been set.").format(service=service)) @_set_api.command(name="list") async def _set_api_list(self, ctx: commands.Context): diff --git a/redbot/core/utils/views.py b/redbot/core/utils/views.py new file mode 100644 index 000000000..45db6c51b --- /dev/null +++ b/redbot/core/utils/views.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import discord + +from discord.ext.commands import BadArgument +from typing import List, Dict, Union, Optional +from redbot.core.commands.converter import get_dict_converter +from redbot.core.i18n import Translator + +_ = Translator("UtilsViews", __file__) + + +class SetApiModal(discord.ui.Modal): + """ + A secure ``discord.ui.Modal`` used to set API keys. + + This Modal can either be used standalone with its own ``discord.ui.View`` + for custom implementations, or created via ``SetApiView`` + to have an easy to implemement secure way of setting API keys. + + Parameters + ---------- + default_service: Optional[str] + The service to add the API keys to. + If this is omitted the bot owner is allowed to set his own service. + Defaults to ``None``. + default_keys: Optional[Dict[str, str]] + The API keys the service is expecting. + This will only allow the bot owner to set keys the Modal is expecting. + Defaults to ``None``. + """ + + def __init__( + self, + default_service: Optional[str] = None, + default_keys: Optional[Dict[str, str]] = None, + ): + self.default_service = default_service + self.default_keys: List[str] = [] + if default_keys is not None: + self.default_keys = list(default_keys.keys()) + self.default_keys_fmt = self._format_keys(default_keys) + + _placeholder_service = "service" + if self.default_service is not None: + _placeholder_service = self.default_service + _placeholder_token = "client_id YOUR_CLIENT_ID\nclient_secret YOUR_CLIENT_SECRET" + if self.default_keys_fmt is not None: + _placeholder_token = self.default_keys_fmt + + self.title = _("Set API Keys") + self.keys_label = _("Keys and tokens") + if self.default_service is not None: + self.title = _("Set API Keys for {service}").format(service=self.default_service) + self.keys_label = _("Keys and tokens for {service}").format( + service=self.default_service + ) + self.default_service = self.default_service.lower() + # Lower here to prevent someone from capitalizing a service name for the sake of UX. + + super().__init__(title=self.title) + + self.service_input = discord.ui.TextInput( + label=_("Service"), + required=True, + placeholder=_placeholder_service, + default=self.default_service, + ) + + self.token_input = discord.ui.TextInput( + label=self.keys_label, + style=discord.TextStyle.long, + required=True, + placeholder=_placeholder_token, + default=self.default_keys_fmt, + ) + + if self.default_service is None: + self.add_item(self.service_input) + self.add_item(self.token_input) + + @staticmethod + def _format_keys(keys: Optional[Dict[str, str]]) -> Optional[str]: + """Format the keys to be used on a long discord.TextInput format""" + if keys is not None: + ret = "" + for k, v in keys.items(): + if v: + ret += f"{k} {v}\n" + else: + ret += f"{k} YOUR_{k.upper()}\n" + return ret + else: + return None + + async def on_submit(self, interaction: discord.Interaction): + if not await interaction.client.is_owner( + interaction.user + ): # Prevent non-bot owners from somehow aquiring and saving the modal. + return await interaction.response.send_message( + _("This modal is for bot owners only. Whoops!"), ephemeral=True + ) + + if self.default_keys is not None: + converter = get_dict_converter(*self.default_keys, delims=[";", ",", " "]) + else: + converter = get_dict_converter(delims=[";", ",", " "]) + tokens = " ".join(self.token_input.value.split("\n")).rstrip() + + try: + tokens = await converter().convert(None, tokens) + except BadArgument as exc: + return await interaction.response.send_message( + _("{error_message}\nPlease try again.").format(error_message=str(exc)), + ephemeral=True, + ) + + if self.default_service is not None: # Check is there is a service set. + await interaction.client.set_shared_api_tokens(self.default_service, **tokens) + return await interaction.response.send_message( + _("`{service}` API tokens have been set.").format(service=self.default_service), + ephemeral=True, + ) + else: + service = self.service_input.value.lower() + await interaction.client.set_shared_api_tokens(service, **tokens) + return await interaction.response.send_message( + _("`{service}` API tokens have been set.").format(service=service), + ephemeral=True, + ) + + +class SetApiView(discord.ui.View): + """ + A secure ``discord.ui.View`` used to set API keys. + + This view is an standalone, easy to implement ``discord.ui.View`` + to allow an bot owner to securely set API keys in a public environment. + + Parameters + ---------- + default_service: Optional[str] + The service to add the API keys to. + If this is omitted the bot owner is allowed to set his own service. + Defaults to ``None``. + default_keys: Optional[Dict[str, str]] + The API keys the service is expecting. + This will only allow the bot owner to set keys the Modal is expecting. + Defaults to ``None``. + """ + + def __init__( + self, + default_service: Optional[str] = None, + default_keys: Optional[Dict[str, str]] = None, + ): + self.default_service = default_service + self.default_keys = default_keys + super().__init__() + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + if not await interaction.client.is_owner(interaction.user): + await interaction.response.send_message( + _("This button is for bot owners only, oh well."), ephemeral=True + ) + return False + return True + + @discord.ui.button( + label=_("Set API token"), + style=discord.ButtonStyle.grey, + ) + async def auth_button(self, interaction: discord.Interaction, button: discord.Button): + return await interaction.response.send_modal( + SetApiModal(self.default_service, self.default_keys) + )