Skip to content
9 changes: 9 additions & 0 deletions docs/exts/commands/core.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
######
Expand Down Expand Up @@ -97,4 +99,11 @@ Converters
:members:

.. autoclass:: twitchio.ext.commands.ColourConverter()
:members:


Translators
###########

.. autoclass:: twitchio.ext.commands.Translator()
:members:
3 changes: 3 additions & 0 deletions docs/exts/commands/exceptions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ Exceptions

.. autoexception:: twitchio.ext.commands.NoEntryPointError

.. autoexception:: twitchio.ext.commands.TranslatorError


Exception Hierarchy
~~~~~~~~~~~~~~~~~~~
Expand All @@ -80,6 +82,7 @@ Exception Hierarchy
- :exc:`ExpectedClosingQuoteError`
- :exc:`GuardFailure`
- :exc:`CommandOnCooldown`
- :exc:`TranslatorError`
- :exc:`ModuleError`
- :exc:`ModuleLoadFailure`
- :exc:`ModuleAlreadyLoadedError`
Expand Down
34 changes: 34 additions & 0 deletions docs/getting-started/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <https://github.com/PythonistaGuild/TwitchIO/pull/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`
Expand Down
1 change: 1 addition & 0 deletions twitchio/ext/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@
from .cooldowns import *
from .core import *
from .exceptions import *
from .translators import *
162 changes: 162 additions & 0 deletions twitchio/ext/commands/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]]

Expand Down Expand Up @@ -543,6 +544,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 | None = getattr(self.command, "translator", None)
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 not code:
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|

Expand Down Expand Up @@ -588,6 +663,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 | None = getattr(self.command, "translator", None)
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 not code:
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:
Expand Down
42 changes: 42 additions & 0 deletions twitchio/ext/commands/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,15 @@
"is_staff",
"is_vip",
"reward_command",
"translator",
)


if TYPE_CHECKING:
from twitchio.user import Chatter

from .context import Context
from .translators import Translator
from .types_ import BotT

P = ParamSpec("P")
Expand Down Expand Up @@ -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 | type[Translator] | None = getattr(callback, "__command_translator__", None)
if translator and inspect.isclass(translator):
translator = translator()

self._translator: Translator | None = translator

self._help: str = callback.__doc__ or ""
self.__doc__ = self._help

Expand Down Expand Up @@ -262,6 +270,13 @@ def _get_signature(self) -> None:

self._signature = help_sig

@property
def translator(self) -> Translator | 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
Expand Down Expand Up @@ -1467,6 +1482,33 @@ def wrapper(
return wrapper


def translator(cls: Translator | type[Translator]) -> 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.
Expand Down
Loading