Skip to content

Commit

Permalink
feat(components): support premium buttons (#1276)
Browse files Browse the repository at this point in the history
Signed-off-by: vi <8530778+shiftinv@users.noreply.github.com>
Co-authored-by: Eneg <42005170+Enegg@users.noreply.github.com>
  • Loading branch information
shiftinv and Enegg authored Feb 5, 2025
1 parent a72a457 commit 2ab0c53
Show file tree
Hide file tree
Showing 9 changed files with 96 additions and 23 deletions.
1 change: 1 addition & 0 deletions changelog/1276.deprecate.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
:meth:`InteractionResponse.require_premium` is deprecated in favor of premium buttons (see :attr:`ui.Button.sku_id`).
1 change: 1 addition & 0 deletions changelog/1276.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Support premium buttons using :attr:`ui.Button.sku_id`.
15 changes: 13 additions & 2 deletions disnake/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
try_enum,
)
from .partial_emoji import PartialEmoji, _EmojiTag
from .utils import MISSING, assert_never, get_slots
from .utils import MISSING, _get_as_snowflake, assert_never, get_slots

if TYPE_CHECKING:
from typing_extensions import Self, TypeAlias
Expand Down Expand Up @@ -184,7 +184,7 @@ class Button(Component):
The style of the button.
custom_id: Optional[:class:`str`]
The ID of the button that gets received during an interaction.
If this button is for a URL, it does not have a custom ID.
If this button is for a URL or an SKU, it does not have a custom ID.
url: Optional[:class:`str`]
The URL this button sends you to.
disabled: :class:`bool`
Expand All @@ -193,6 +193,11 @@ class Button(Component):
The label of the button, if any.
emoji: Optional[:class:`PartialEmoji`]
The emoji of the button, if available.
sku_id: Optional[:class:`int`]
The ID of a purchasable SKU, for premium buttons.
Premium buttons additionally cannot have a ``label``, ``url``, or ``emoji``.
.. versionadded:: 2.11
"""

__slots__: Tuple[str, ...] = (
Expand All @@ -202,6 +207,7 @@ class Button(Component):
"disabled",
"label",
"emoji",
"sku_id",
)

__repr_info__: ClassVar[Tuple[str, ...]] = __slots__
Expand All @@ -219,6 +225,8 @@ def __init__(self, data: ButtonComponentPayload) -> None:
except KeyError:
self.emoji = None

self.sku_id: Optional[int] = _get_as_snowflake(data, "sku_id")

def to_dict(self) -> ButtonComponentPayload:
payload: ButtonComponentPayload = {
"type": self.type.value,
Expand All @@ -238,6 +246,9 @@ def to_dict(self) -> ButtonComponentPayload:
if self.emoji:
payload["emoji"] = self.emoji.to_dict()

if self.sku_id:
payload["sku_id"] = self.sku_id

return payload


Expand Down
13 changes: 13 additions & 0 deletions disnake/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -1148,6 +1148,9 @@ class InteractionResponseType(Enum):
See also :meth:`InteractionResponse.require_premium`.
.. versionadded:: 2.10
.. deprecated:: 2.11
Use premium buttons (:class:`ui.Button` with :attr:`~ui.Button.sku_id`) instead.
"""


Expand Down Expand Up @@ -1226,6 +1229,11 @@ class ButtonStyle(Enum):
"""Represents a red button for a dangerous action."""
link = 5
"""Represents a link button."""
premium = 6
"""Represents a premium/SKU button.
.. versionadded:: 2.11
"""

# Aliases
blurple = 1
Expand All @@ -1240,6 +1248,11 @@ class ButtonStyle(Enum):
"""An alias for :attr:`danger`."""
url = 5
"""An alias for :attr:`link`."""
sku = 6
"""An alias for :attr:`premium`.
.. versionadded:: 2.11
"""

def __int__(self) -> int:
return self.value
Expand Down
4 changes: 4 additions & 0 deletions disnake/interactions/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1483,6 +1483,7 @@ async def send_modal(
if modal is not None:
parent._state.store_modal(parent.author.id, modal)

@utils.deprecated("premium buttons")
async def require_premium(self) -> None:
"""|coro|
Expand All @@ -1492,6 +1493,9 @@ async def require_premium(self) -> None:
.. versionadded:: 2.10
.. deprecated:: 2.11
Use premium buttons (:class:`ui.Button` with :attr:`~ui.Button.sku_id`) instead.
Example
-------
Require an application subscription for a command: ::
Expand Down
3 changes: 2 additions & 1 deletion disnake/types/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from .snowflake import Snowflake

ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8]
ButtonStyle = Literal[1, 2, 3, 4, 5]
ButtonStyle = Literal[1, 2, 3, 4, 5, 6]
TextInputStyle = Literal[1, 2]

SelectDefaultValueType = Literal["user", "role", "channel"]
Expand All @@ -32,6 +32,7 @@ class ButtonComponent(TypedDict):
custom_id: NotRequired[str]
url: NotRequired[str]
disabled: NotRequired[bool]
sku_id: NotRequired[Snowflake]


class SelectOption(TypedDict):
Expand Down
7 changes: 7 additions & 0 deletions disnake/ui/action_row.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ def add_button(
custom_id: Optional[str] = None,
url: Optional[str] = None,
emoji: Optional[Union[str, Emoji, PartialEmoji]] = None,
sku_id: Optional[int] = None,
) -> ButtonCompatibleActionRowT:
"""Add a button to the action row. Can only be used if the action
row holds message components.
Expand Down Expand Up @@ -278,6 +279,11 @@ def add_button(
The label of the button, if any.
emoji: Optional[Union[:class:`.PartialEmoji`, :class:`.Emoji`, :class:`str`]]
The emoji of the button, if available.
sku_id: Optional[:class:`int`]
The ID of a purchasable SKU, for premium buttons.
Premium buttons additionally cannot have a ``label``, ``url``, or ``emoji``.
.. versionadded:: 2.11
Raises
------
Expand All @@ -293,6 +299,7 @@ def add_button(
custom_id=custom_id,
url=url,
emoji=emoji,
sku_id=sku_id,
),
)
return self
Expand Down
50 changes: 40 additions & 10 deletions disnake/ui/button.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ class Button(Item[V_co]):
The style of the button.
custom_id: Optional[:class:`str`]
The ID of the button that gets received during an interaction.
If this button is for a URL, it does not have a custom ID.
If this button is for a URL or an SKU, it does not have a custom ID.
url: Optional[:class:`str`]
The URL this button sends you to.
disabled: :class:`bool`
Expand All @@ -62,6 +62,11 @@ class Button(Item[V_co]):
The label of the button, if any.
emoji: Optional[Union[:class:`.PartialEmoji`, :class:`.Emoji`, :class:`str`]]
The emoji of the button, if available.
sku_id: Optional[:class:`int`]
The ID of a purchasable SKU, for premium buttons.
Premium buttons additionally cannot have a ``label``, ``url``, or ``emoji``.
.. versionadded:: 2.11
row: Optional[:class:`int`]
The relative row this button belongs to. A Discord component can only have 5
rows. By default, items are arranged automatically into those 5 rows. If you'd
Expand All @@ -76,6 +81,7 @@ class Button(Item[V_co]):
"disabled",
"label",
"emoji",
"sku_id",
"row",
)
# We have to set this to MISSING in order to overwrite the abstract property from WrappedComponent
Expand All @@ -91,6 +97,7 @@ def __init__(
custom_id: Optional[str] = None,
url: Optional[str] = None,
emoji: Optional[Union[str, Emoji, PartialEmoji]] = None,
sku_id: Optional[int] = None,
row: Optional[int] = None,
) -> None: ...

Expand All @@ -104,6 +111,7 @@ def __init__(
custom_id: Optional[str] = None,
url: Optional[str] = None,
emoji: Optional[Union[str, Emoji, PartialEmoji]] = None,
sku_id: Optional[int] = None,
row: Optional[int] = None,
) -> None: ...

Expand All @@ -116,18 +124,23 @@ def __init__(
custom_id: Optional[str] = None,
url: Optional[str] = None,
emoji: Optional[Union[str, Emoji, PartialEmoji]] = None,
sku_id: Optional[int] = None,
row: Optional[int] = None,
) -> None:
super().__init__()
if custom_id is not None and url is not None:
raise TypeError("cannot mix both url and custom_id with Button")

self._provided_custom_id = custom_id is not None
if url is None and custom_id is None:
mutually_exclusive = 3 - (custom_id, url, sku_id).count(None)

if mutually_exclusive == 0:
custom_id = os.urandom(16).hex()
elif mutually_exclusive != 1:
raise TypeError("cannot mix url, sku_id and custom_id with Button")

if url is not None:
style = ButtonStyle.link
if sku_id is not None:
style = ButtonStyle.premium

if emoji is not None:
if isinstance(emoji, str):
Expand All @@ -147,6 +160,7 @@ def __init__(
label=label,
style=style,
emoji=emoji,
sku_id=sku_id,
)
self.row = row

Expand All @@ -167,7 +181,7 @@ def style(self, value: ButtonStyle) -> None:
def custom_id(self) -> Optional[str]:
"""Optional[:class:`str`]: The ID of the button that gets received during an interaction.
If this button is for a URL, it does not have a custom ID.
If this button is for a URL or an SKU, it does not have a custom ID.
"""
return self._underlying.custom_id

Expand Down Expand Up @@ -226,6 +240,20 @@ def emoji(self, value: Optional[Union[str, Emoji, PartialEmoji]]) -> None:
else:
self._underlying.emoji = None

@property
def sku_id(self) -> Optional[int]:
"""Optional[:class:`int`]: The ID of a purchasable SKU, for premium buttons.
.. versionadded:: 2.11
"""
return self._underlying.sku_id

@sku_id.setter
def sku_id(self, value: Optional[int]) -> None:
if value is not None and not isinstance(value, int):
raise TypeError("sku_id must be None or int")
self._underlying.sku_id = value

@classmethod
def from_component(cls, button: ButtonComponent) -> Self:
return cls(
Expand All @@ -235,6 +263,7 @@ def from_component(cls, button: ButtonComponent) -> Self:
custom_id=button.custom_id,
url=button.url,
emoji=button.emoji,
sku_id=button.sku_id,
row=None,
)

Expand All @@ -244,6 +273,8 @@ def is_dispatchable(self) -> bool:
def is_persistent(self) -> bool:
if self.style is ButtonStyle.link:
return self.url is not None
elif self.style is ButtonStyle.premium:
return self.sku_id is not None
return super().is_persistent()

def refresh_component(self, button: ButtonComponent) -> None:
Expand Down Expand Up @@ -279,11 +310,10 @@ def button(
.. note::
Buttons with a URL cannot be created with this function.
Consider creating a :class:`Button` manually instead.
This is because buttons with a URL do not have a callback
associated with them since Discord does not do any processing
with it.
Link/Premium buttons cannot be created with this function,
since these buttons do not have a callback associated with them.
Consider creating a :class:`Button` manually instead, and adding it
using :meth:`View.add_item`.
Parameters
----------
Expand Down
25 changes: 15 additions & 10 deletions disnake/ui/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@
UserSelectMenu as UserSelectComponent,
_component_factory,
)
from ..enums import ComponentType, try_enum_to_int
from ..enums import try_enum_to_int
from ..utils import assert_never
from .button import Button
from .item import Item

__all__ = ("View",)
Expand Down Expand Up @@ -412,7 +413,7 @@ def _dispatch_item(self, item: Item, interaction: MessageInteraction) -> None:
)

def refresh(self, components: List[ActionRowComponent[MessageComponent]]) -> None:
# TODO: this is pretty hacky at the moment
# TODO: this is pretty hacky at the moment, see https://github.com/DisnakeDev/disnake/commit/9384a72acb8c515b13a600592121357e165368da
old_state: Dict[Tuple[int, str], Item] = {
(item.type.value, item.custom_id): item # type: ignore
for item in self.children
Expand All @@ -424,21 +425,24 @@ def refresh(self, components: List[ActionRowComponent[MessageComponent]]) -> Non
try:
older = old_state[(component.type.value, component.custom_id)] # type: ignore
except (KeyError, AttributeError):
# workaround for url buttons, since they're not part of `old_state`
# workaround for non-interactive buttons, since they're not part of `old_state`
if isinstance(component, ButtonComponent):
for child in self.children:
if not isinstance(child, Button):
continue
# try finding the corresponding child in this view based on other attributes
if (
child.type is ComponentType.button
and child.label == component.label # type: ignore
and child.url == component.url # type: ignore
):
(child.label and child.label == component.label)
and (child.url and child.url == component.url)
) or (child.sku_id and child.sku_id == component.sku_id):
older = child
break

if older:
older.refresh_component(component)
older.refresh_component(component) # type: ignore # this is fine, pyright is trying to be smart
children.append(older)
else:
# fallback, should not happen as long as implementation covers all cases
children.append(_component_to_item(component))

self.children = children
Expand Down Expand Up @@ -477,8 +481,9 @@ def is_dispatching(self) -> bool:
def is_persistent(self) -> bool:
"""Whether the view is set up as persistent.
A persistent view has all their components with a set ``custom_id`` and
a :attr:`timeout` set to ``None``.
A persistent view only has components with a set ``custom_id``
(or non-interactive components such as :attr:`~.ButtonStyle.link` or :attr:`~.ButtonStyle.premium` buttons),
and a :attr:`timeout` set to ``None``.
:return type: :class:`bool`
"""
Expand Down

0 comments on commit 2ab0c53

Please sign in to comment.