Skip to content

feat(components): support premium buttons #1276

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Feb 5, 2025
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
Loading