diff --git a/docs/exts/commands/core.rst b/docs/exts/commands/core.rst index 6d5a45c1..f072b4ce 100644 --- a/docs/exts/commands/core.rst +++ b/docs/exts/commands/core.rst @@ -54,6 +54,8 @@ Decorators .. autofunction:: twitchio.ext.commands.cooldown(*, base: BaseCooldown, rate: int, per: float, key: Callable[[Any], Hashable] | Callable[[Any], Coroutine[Any, Any, Hashable]] | BucketType, **kwargs: ~typing.Any) +.. autofunction:: twitchio.ext.commands.translator + Guards ###### @@ -97,4 +99,11 @@ Converters :members: .. autoclass:: twitchio.ext.commands.ColourConverter() + :members: + + +Translators +########### + +.. autoclass:: twitchio.ext.commands.Translator() :members: \ No newline at end of file diff --git a/docs/exts/commands/exceptions.rst b/docs/exts/commands/exceptions.rst index 779202d8..8f50f4f7 100644 --- a/docs/exts/commands/exceptions.rst +++ b/docs/exts/commands/exceptions.rst @@ -57,6 +57,8 @@ Exceptions .. autoexception:: twitchio.ext.commands.NoEntryPointError +.. autoexception:: twitchio.ext.commands.TranslatorError + Exception Hierarchy ~~~~~~~~~~~~~~~~~~~ @@ -80,6 +82,7 @@ Exception Hierarchy - :exc:`ExpectedClosingQuoteError` - :exc:`GuardFailure` - :exc:`CommandOnCooldown` + - :exc:`TranslatorError` - :exc:`ModuleError` - :exc:`ModuleLoadFailure` - :exc:`ModuleAlreadyLoadedError` diff --git a/docs/getting-started/changelog.rst b/docs/getting-started/changelog.rst index 95937776..cb15ade2 100644 --- a/docs/getting-started/changelog.rst +++ b/docs/getting-started/changelog.rst @@ -12,14 +12,26 @@ Changelog - twitchio - Additions - Added ``__hash__`` to :class:`twitchio.PartialUser` allowing it to be used as a key. + - Added the ``--create-new`` interactive script to ``__main__`` allowing boiler-plate to be generated for a new Bot. - Changes - Adjusted the Starlette logging warning wording. + - Delayed the Starlette logging warning and removed it from ``web/__init__.py``. - :class:`twitchio.PartialUser`, :class:`twitchio.User` and :class:`twitchio.Chatter` now have ``__hash__`` implementations derived from :class:`~twitchio.PartialUser`, which use the unique ID. - Bug fixes - :meth:`twitchio.Clip.fetch_video` now properly returns ``None`` when the :class:`twitchio.Clip` has no ``video_id``. - :class:`twitchio.ChatterColor` no longer errors whan no valid hex is provided by Twitch. + - Some general typing/spelling errors cleaned up in Documentation and Logging. + - Removed some redundant logging. + +- twitchio.AutoClient + - Additions + - Added ``force_subscribe`` keyword argument to :class:`twitchio.AutoClient`, allowing subscriptions passed to be made everytime the client is started. + +- twitchio.ext.commands.AutoBot + - Additions + - Added ``force_subscribe`` keyword argument to :class:`twitchio.ext.commands.AutoBot`, allowing subscriptions passed to be made everytime the bot is started. - twitchio.eventsub - Additions @@ -82,13 +94,35 @@ Changelog - Added :meth:`twitchio.ShoutoutReceive.respond` - Added :meth:`twitchio.StreamOnline.respond` - Added :meth:`twitchio.StreamOffline.respond` + + - Bug fixes + - Remove the unnecessary ``token_for`` parameter from :meth:`twitchio.ChannelPointsReward.fetch_reward`. `#510 `_ + +- twitchio.web.AiohttpAdapter + - Bug fixes + - Fixed the redirect URL not allowing HOST/PORT when a custom domain was passed. + - The redirect URL is now determined based on where the request came from. + +- twitchio.web.StarletteAdapter + - Bug fixes + - Fixed the redirect URL not allowing HOST/PORT when a custom domain was passed. + - The redirect URL is now determined based on where the request came from. + - Fixed Uvicorn hanging the process when attempting to close the :class:`asyncio.Loop` on **Windows**. + - After ``5 seconds`` Uvicorn will be forced closed if it cannot gracefully close in this time. - ext.commands - Additions + - Added :class:`~twitchio.ext.commands.Translator` + - Added :func:`~twitchio.ext.commands.translator` + - Added :attr:`twitchio.ext.commands.Command.translator` + - Added :meth:`twitchio.ext.commands.Context.send_translated` + - Added :meth:`twitchio.ext.commands.Context.reply_translated` - Added :class:`~twitchio.ext.commands.Converter` - Added :class:`~twitchio.ext.commands.UserConverter` - Added :class:`~twitchio.ext.commands.ColourConverter` - Added :class:`~twitchio.ext.commands.ColorConverter` alias. + - Added :attr:`twitchio.ext.commands.Command.signature` which is a POSIX-like signature for the command. + - Added :attr:`twitchio.ext.commands.Command.parameters` which is a mapping of parameter name to :class:`inspect.Parameter` associated with the command callback. - Added :attr:`twitchio.ext.commands.Command.help` which is the docstring of the command callback. - Added ``__doc__`` to :class:`~twitchio.ext.commands.Command` which takes from the callback ``__doc__``. - Added :meth:`twitchio.ext.commands.Command.run_guards` diff --git a/twitchio/ext/commands/__init__.py b/twitchio/ext/commands/__init__.py index 58fd5a6d..cdeda663 100644 --- a/twitchio/ext/commands/__init__.py +++ b/twitchio/ext/commands/__init__.py @@ -29,3 +29,4 @@ from .cooldowns import * from .core import * from .exceptions import * +from .translators import * diff --git a/twitchio/ext/commands/context.py b/twitchio/ext/commands/context.py index 5c54ca1d..5035f05c 100644 --- a/twitchio/ext/commands/context.py +++ b/twitchio/ext/commands/context.py @@ -47,6 +47,7 @@ from .bot import Bot from .components import Component from .core import Command + from .translators import Translator PrefixT: TypeAlias = str | Iterable[str] | Callable[[Bot, ChatMessage], Coroutine[Any, Any, str | Iterable[str]]] @@ -255,6 +256,15 @@ def type(self) -> ContextType: """ return self._type + @property + def translator(self) -> Translator[Any] | None: + """Property returning the :class:`.commands.Translator` assigned to the :class:`.commands.Command` if found or + ``None``. This property will always return ``None`` if no valid command or prefix is associated with this Context.""" + if not self.command: + return None + + return self.command.translator + @property def error_dispatched(self) -> bool: return self._error_dispatched @@ -543,6 +553,80 @@ async def send(self, content: str, *, me: bool = False) -> SentMessage: new = (f"/me {content}" if me else content).strip() return await self.channel.send_message(sender=self.bot.bot_id, message=new) + async def send_translated(self, content: str, *, me: bool = False, langcode: str | None = None) -> SentMessage: + """|coro| + + Send a translated chat message to the channel associated with this context. + + You must have added a :class:`.commands.Translator` to your :class:`.commands.Command` in order to effectively use + this method. If no :class:`.commands.Translator` is found, this method acts identical to :meth:`.send`. + + If this method can not find a valid language code, E.g. both :meth:`.commands.Translator.get_langcode` and the parameter + ``langcode`` return ``None``, this method acts identical to :meth:`.send`. + + See the following documentation for more details on translators: + + - :class:`.commands.Translator` + - :func:`.commands.translator` + + .. important:: + + You must have the ``user:write:chat`` scope. If an app access token is used, + then additionally requires the ``user:bot`` scope on the bot, + and either ``channel:bot`` scope from the broadcaster or moderator status. + + Parameters + ---------- + content: str + The content of the message you would like to translate and then send. + This **and** the translated version of this content cannot exceed ``500`` characters. + Additionally the content parameter will be stripped of all leading and trailing whitespace. + me: bool + An optional bool indicating whether you would like to send this message with the ``/me`` chat command. + langcode: str | None + An optional ``langcode`` to override the ``langcode`` returned from :meth:`.commands.Translator.get_langcode`. + This should only be provided if you do custom language code lookups outside of your + :class:`.commands.Translator`. Defaults to ``None``. + + + Returns + ------- + SentMessage + The payload received by Twitch after sending this message. + + Raises + ------ + HTTPException + Twitch failed to process the message, could be ``400``, ``401``, ``403``, ``422`` or any ``5xx`` status code. + MessageRejectedError + Twitch rejected the message from various checks. + TranslatorError + An error occurred during translation. + """ + translator: Translator[Any] | None = self.translator + new = (f"/me {content}" if me else content).strip() + + if not self.command or not translator: + return await self.channel.send_message(sender=self.bot.bot_id, message=new) + + invoked = self.invoked_with + + try: + code = langcode or translator.get_langcode(self, invoked.lower()) if invoked else None + except Exception as e: + raise TranslatorError(f"An exception occurred fetching a language code for '{invoked}'.", original=e) from e + + if code is None: + return await self.channel.send_message(sender=self.bot.bot_id, message=new) + + try: + translated = await translator.translate(self, content, code) + except Exception as e: + raise TranslatorError(f"An exception occurred translating content for '{invoked}'.", original=e) from e + + new_translated = (f"/me {translated}" if me else translated).strip() + return await self.channel.send_message(sender=self.bot.bot_id, message=new_translated) + async def reply(self, content: str, *, me: bool = False) -> SentMessage: """|coro| @@ -588,6 +672,93 @@ async def reply(self, content: str, *, me: bool = False) -> SentMessage: new = (f"/me {content}" if me else content).strip() return await self.channel.send_message(sender=self.bot.bot_id, message=new, reply_to_message_id=self._payload.id) + async def reply_translated(self, content: str, *, me: bool = False, langcode: str | None = None) -> SentMessage: + """|coro| + + Send a translated chat message as a reply to the user who this message is associated with and to the channel associated with + this context. + + You must have added a :class:`.commands.Translator` to your :class:`.commands.Command` in order to effectively use + this method. If no :class:`.commands.Translator` is found, this method acts identical to :meth:`.reply`. + + If this method can not find a valid language code, E.g. both :meth:`.commands.Translator.get_langcode` and the parameter + ``langcode`` return ``None``, this method acts identical to :meth:`.reply`. + + See the following documentation for more details on translators: + + - :class:`.commands.Translator` + - :func:`.commands.translator` + + .. warning:: + + You cannot use this method in Reward based context. E.g. + if :attr:`~.commands.Context.type` is :attr:`~.commands.ContextType.REWARD`. + + .. important:: + + You must have the ``user:write:chat`` scope. If an app access token is used, + then additionally requires the ``user:bot`` scope on the bot, + and either ``channel:bot`` scope from the broadcaster or moderator status. + + Parameters + ---------- + content: str + The content of the message you would like to translate and then send. + This **and** the translated version of this content cannot exceed ``500`` characters. + Additionally the content parameter will be stripped of all leading and trailing whitespace. + me: bool + An optional bool indicating whether you would like to send this message with the ``/me`` chat command. + langcode: str | None + An optional ``langcode`` to override the ``langcode`` returned from :meth:`.commands.Translator.get_langcode`. + This should only be provided if you do custom language code lookups outside of your + :class:`.commands.Translator`. Defaults to ``None``. + + + Returns + ------- + SentMessage + The payload received by Twitch after sending this message. + + Raises + ------ + HTTPException + Twitch failed to process the message, could be ``400``, ``401``, ``403``, ``422`` or any ``5xx`` status code. + MessageRejectedError + Twitch rejected the message from various checks. + TranslatorError + An error occurred during translation. + """ + if self._type is ContextType.REWARD: + raise TypeError("Cannot reply to a message in a Reward based context.") + + translator: Translator[Any] | None = self.translator + new = (f"/me {content}" if me else content).strip() + + if not self.command or not translator: + return await self.channel.send_message(sender=self.bot.bot_id, message=new, reply_to_message_id=self._payload.id) + + invoked = self.invoked_with + + try: + code = langcode or translator.get_langcode(self, invoked.lower()) if invoked else None + except Exception as e: + raise TranslatorError(f"An exception occurred fetching a language code for '{invoked}'.", original=e) from e + + if code is None: + return await self.channel.send_message(sender=self.bot.bot_id, message=new, reply_to_message_id=self._payload.id) + + try: + translated = await translator.translate(self, content, code) + except Exception as e: + raise TranslatorError(f"An exception occurred translating content for '{invoked}'.", original=e) from e + + new_translated = (f"/me {translated}" if me else translated).strip() + return await self.channel.send_message( + sender=self.bot.bot_id, + message=new_translated, + reply_to_message_id=self._payload.id, + ) + async def send_announcement( self, content: str, *, color: Literal["blue", "green", "orange", "purple", "primary"] | None = None ) -> None: diff --git a/twitchio/ext/commands/core.py b/twitchio/ext/commands/core.py index c8fba808..05f7a65d 100644 --- a/twitchio/ext/commands/core.py +++ b/twitchio/ext/commands/core.py @@ -61,6 +61,7 @@ "is_staff", "is_vip", "reward_command", + "translator", ) @@ -68,6 +69,7 @@ from twitchio.user import Chatter from .context import Context + from .translators import Translator from .types_ import BotT P = ParamSpec("P") @@ -223,6 +225,12 @@ def __init__( self._before_hook: Callable[[Component_T, Context[Any]], Coro] | Callable[[Context[Any]], Coro] | None = None self._after_hook: Callable[[Component_T, Context[Any]], Coro] | Callable[[Context[Any]], Coro] | None = None + translator: Translator[Any] | type[Translator[Any]] | None = getattr(callback, "__command_translator__", None) + if translator and inspect.isclass(translator): + translator = translator() + + self._translator: Translator[Any] | None = translator + self._help: str = callback.__doc__ or "" self.__doc__ = self._help @@ -262,6 +270,13 @@ def _get_signature(self) -> None: self._signature = help_sig + @property + def translator(self) -> Translator[Any] | None: + """Property returning the :class:`.commands.Translator` associated with this command or ``None`` if one was not + used. + """ + return self._translator + @property def parameters(self) -> MappingProxyType[str, inspect.Parameter]: """Property returning a copy mapping of name to :class:`inspect.Parameter` pair, which are the parameters @@ -1467,6 +1482,33 @@ def wrapper( return wrapper +def translator(cls: Translator[Any] | type[Translator[Any]]) -> Any: + """|deco| + + Decorator which adds a :class:`.commands.Translator` to a :class:`.commands.Command`. + + You can provide the class or instance of your implemented :class:`.commands.Translator` to this decorator. + + See the :class:`.commands.Translator` documentation for more information on translators. + + .. note:: + + You can only have one :class:`.commands.Translator` per command. + """ + + def wrapper(func: Any) -> Any: + inst = cls() if inspect.isclass(cls) else cls + + if isinstance(func, Command): + func._translator = inst + else: + func.__command_translator = inst + + return func # type: ignore + + return wrapper + + def guard(predicate: Callable[..., bool] | Callable[..., CoroC]) -> Any: """A function which takes in a predicate as a either a standard function *or* coroutine function which should return either ``True`` or ``False``, and adds it to your :class:`~.commands.Command` as a guard. diff --git a/twitchio/ext/commands/exceptions.py b/twitchio/ext/commands/exceptions.py index bb2304a5..4318d47d 100644 --- a/twitchio/ext/commands/exceptions.py +++ b/twitchio/ext/commands/exceptions.py @@ -51,6 +51,7 @@ "ModuleNotLoadedError", "NoEntryPointError", "PrefixError", + "TranslatorError", "UnexpectedQuoteError", ) @@ -72,7 +73,7 @@ class CommandInvokeError(CommandError): Attributes ---------- original: :class:`Exception` | None - The original exception that caused this error. Could be None. + The original exception that caused this error. Could be ``None``. """ def __init__(self, msg: str | None = None, original: Exception | None = None) -> None: @@ -204,3 +205,18 @@ def __init__(self, msg: str | None = None, *, cooldown: BaseCooldown, remaining: self.cooldown: BaseCooldown = cooldown self.remaining: float = remaining super().__init__(msg or f"Cooldown is ratelimited. Try again in {remaining} seconds.") + + +class TranslatorError(CommandError): + """Exception raised when a :class:`.commands.Translator` raises an error while attempting to get a language code + or translate content provided to :meth:`~.commands.Context.send_translated`. + + Attributes + ---------- + original: :class:`Exception` | None + The original exception that caused this error. Could be ``None``. + """ + + def __init__(self, msg: str | None = None, original: Exception | None = None) -> None: + self.original: Exception | None = original + super().__init__(msg) diff --git a/twitchio/ext/commands/translators.py b/twitchio/ext/commands/translators.py new file mode 100644 index 00000000..4f4b3fe9 --- /dev/null +++ b/twitchio/ext/commands/translators.py @@ -0,0 +1,157 @@ +""" +MIT License + +Copyright (c) 2017 - Present PythonistaGuild + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +from __future__ import annotations + +import abc +from typing import TYPE_CHECKING, Any, Generic, TypeVar + + +if TYPE_CHECKING: + from .context import Context + + +T = TypeVar("T") + + +__all__ = ("Translator",) + + +class Translator(Generic[T], abc.ABC): + """Abstract Base Class for command translators. + + This class allows you to implement logic to translate messages sent via the :meth:`.commands.Context.send_translated` + method in commands or anywhere :class:`.commands.Context` is available. + + You should pass your implemented class to the :func:`.commands.translator` decorator on top of a :class:`~.commands.Command`. + + .. important:: + + You must implement every method of this ABC. + """ + + @abc.abstractmethod + def get_langcode(self, ctx: Context[Any], name: str) -> T | None: + """Method which is called when :meth:`.commands.Context.send_translated` is used on a :class:`.commands.Command` + which has an associated Translator, to determine the ``langcode`` which should be passed to :meth:`.translate` or ``None`` + if the content should not be translated. + + By default the lowercase ``name`` or ``alias`` used to invoke the command is passed alongside :class:`.commands.Context` + to aid in determining the ``langcode`` you should use. + + You can use any format or type for the language codes. We recommend using a recognized system such as the ``ISO 639`` + language code format as a :class:`str`. + + Parameters + ---------- + ctx: commands.Context + The context surrounding the command invocation. + name: str + The ``name`` or ``alias`` used to invoke the command. This does not include the prefix, however if you need to + retrieve the prefix see: :attr:`.commands.Context.prefix`. + + Returns + ------- + Any + The language code to pass to :meth:`.translate`. + None + No translation attempt should be made with :meth:`.translate`. + + Example + ------- + + .. code:: python3 + + # For example purposes only the "get_langcode" method is shown in this example... + # The "translate" method must also be implemented... + + class HelloTranslator(commands.Translator[str]): + def __init__(self) -> None: + self.code_mapping = {"hello": "en", "bonjour": "fr"} + + def get_langcode(self, ctx: commands.Context, name: str) -> str | None: + # Return a default of "en". Could also be ``None`` to prevent `translate` being called and to + # send the default message... + return self.code_mapping.get(name, "en") + + + @commands.command(name="hello", aliases=["bonjour"]) + @commands.translator(HelloTranslator) + async def hello_command(ctx: commands.Context) -> None: + await ctx.send_translated("Hello!") + """ + + @abc.abstractmethod + async def translate(self, ctx: Context[Any], text: str, langcode: T) -> str: + """|coro| + + Method used to translate the content passed to :meth:`.commands.Context.send_translated` with the language code returned from + :meth:`.get_langcode`. If ``None`` is returned from :meth:`.get_langcode`, this method will not be called and the + default content provided to :meth:`~.commands.Context.send_translated` will be sent instead. + + You could use this method to call a local or external translation API, retrieve translations from a database or local + mapping etc. + + This method must return a :class:`str`. + + Parameters + ---------- + ctx: commands.Context + The context surrounding the command invocation. + text: str + The content passed to :meth:`~.commands.Context.send_translated` which should be translated. + langcode: Any + The language code returned via :meth:`.get_langcode`, which can be used to determine the language the text should + be translated to. + + Returns + ------- + str + The translated text which should be sent. This should not exceed ``500`` characters. + + Example + ------- + + .. code:: python3 + + # For example purposes only the "translate" method is shown in this example... + # The "get_langcode" method must also be implemented... + + class HelloTranslator(commands.Translator[str]): + + async def translate(self, ctx: commands.Context, text: str, langcode: str) -> str: + # Usually you would call an API, or retrieve from a database or dict or some other solution... + # This is just for an example... + + if langcode == "en": + return text + + elif langcode == "fr": + return "Bonjour!" + + @commands.command(name="hello", aliases=["bonjour"]) + @commands.translator(HelloTranslator) + async def hello_command(ctx: commands.Context) -> None: + await ctx.send_translated("Hello!") + """