Skip to content

Commit 2ab0c53

Browse files
shiftinvEnegg
andauthored
feat(components): support premium buttons (#1276)
Signed-off-by: vi <[email protected]> Co-authored-by: Eneg <[email protected]>
1 parent a72a457 commit 2ab0c53

File tree

9 files changed

+96
-23
lines changed

9 files changed

+96
-23
lines changed

changelog/1276.deprecate.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
:meth:`InteractionResponse.require_premium` is deprecated in favor of premium buttons (see :attr:`ui.Button.sku_id`).

changelog/1276.feature.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Support premium buttons using :attr:`ui.Button.sku_id`.

disnake/components.py

+13-2
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
try_enum,
2828
)
2929
from .partial_emoji import PartialEmoji, _EmojiTag
30-
from .utils import MISSING, assert_never, get_slots
30+
from .utils import MISSING, _get_as_snowflake, assert_never, get_slots
3131

3232
if TYPE_CHECKING:
3333
from typing_extensions import Self, TypeAlias
@@ -184,7 +184,7 @@ class Button(Component):
184184
The style of the button.
185185
custom_id: Optional[:class:`str`]
186186
The ID of the button that gets received during an interaction.
187-
If this button is for a URL, it does not have a custom ID.
187+
If this button is for a URL or an SKU, it does not have a custom ID.
188188
url: Optional[:class:`str`]
189189
The URL this button sends you to.
190190
disabled: :class:`bool`
@@ -193,6 +193,11 @@ class Button(Component):
193193
The label of the button, if any.
194194
emoji: Optional[:class:`PartialEmoji`]
195195
The emoji of the button, if available.
196+
sku_id: Optional[:class:`int`]
197+
The ID of a purchasable SKU, for premium buttons.
198+
Premium buttons additionally cannot have a ``label``, ``url``, or ``emoji``.
199+
200+
.. versionadded:: 2.11
196201
"""
197202

198203
__slots__: Tuple[str, ...] = (
@@ -202,6 +207,7 @@ class Button(Component):
202207
"disabled",
203208
"label",
204209
"emoji",
210+
"sku_id",
205211
)
206212

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

228+
self.sku_id: Optional[int] = _get_as_snowflake(data, "sku_id")
229+
222230
def to_dict(self) -> ButtonComponentPayload:
223231
payload: ButtonComponentPayload = {
224232
"type": self.type.value,
@@ -238,6 +246,9 @@ def to_dict(self) -> ButtonComponentPayload:
238246
if self.emoji:
239247
payload["emoji"] = self.emoji.to_dict()
240248

249+
if self.sku_id:
250+
payload["sku_id"] = self.sku_id
251+
241252
return payload
242253

243254

disnake/enums.py

+13
Original file line numberDiff line numberDiff line change
@@ -1148,6 +1148,9 @@ class InteractionResponseType(Enum):
11481148
See also :meth:`InteractionResponse.require_premium`.
11491149
11501150
.. versionadded:: 2.10
1151+
1152+
.. deprecated:: 2.11
1153+
Use premium buttons (:class:`ui.Button` with :attr:`~ui.Button.sku_id`) instead.
11511154
"""
11521155

11531156

@@ -1226,6 +1229,11 @@ class ButtonStyle(Enum):
12261229
"""Represents a red button for a dangerous action."""
12271230
link = 5
12281231
"""Represents a link button."""
1232+
premium = 6
1233+
"""Represents a premium/SKU button.
1234+
1235+
.. versionadded:: 2.11
1236+
"""
12291237

12301238
# Aliases
12311239
blurple = 1
@@ -1240,6 +1248,11 @@ class ButtonStyle(Enum):
12401248
"""An alias for :attr:`danger`."""
12411249
url = 5
12421250
"""An alias for :attr:`link`."""
1251+
sku = 6
1252+
"""An alias for :attr:`premium`.
1253+
1254+
.. versionadded:: 2.11
1255+
"""
12431256

12441257
def __int__(self) -> int:
12451258
return self.value

disnake/interactions/base.py

+4
Original file line numberDiff line numberDiff line change
@@ -1483,6 +1483,7 @@ async def send_modal(
14831483
if modal is not None:
14841484
parent._state.store_modal(parent.author.id, modal)
14851485

1486+
@utils.deprecated("premium buttons")
14861487
async def require_premium(self) -> None:
14871488
"""|coro|
14881489
@@ -1492,6 +1493,9 @@ async def require_premium(self) -> None:
14921493
14931494
.. versionadded:: 2.10
14941495
1496+
.. deprecated:: 2.11
1497+
Use premium buttons (:class:`ui.Button` with :attr:`~ui.Button.sku_id`) instead.
1498+
14951499
Example
14961500
-------
14971501
Require an application subscription for a command: ::

disnake/types/components.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from .snowflake import Snowflake
1212

1313
ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8]
14-
ButtonStyle = Literal[1, 2, 3, 4, 5]
14+
ButtonStyle = Literal[1, 2, 3, 4, 5, 6]
1515
TextInputStyle = Literal[1, 2]
1616

1717
SelectDefaultValueType = Literal["user", "role", "channel"]
@@ -32,6 +32,7 @@ class ButtonComponent(TypedDict):
3232
custom_id: NotRequired[str]
3333
url: NotRequired[str]
3434
disabled: NotRequired[bool]
35+
sku_id: NotRequired[Snowflake]
3536

3637

3738
class SelectOption(TypedDict):

disnake/ui/action_row.py

+7
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,7 @@ def add_button(
247247
custom_id: Optional[str] = None,
248248
url: Optional[str] = None,
249249
emoji: Optional[Union[str, Emoji, PartialEmoji]] = None,
250+
sku_id: Optional[int] = None,
250251
) -> ButtonCompatibleActionRowT:
251252
"""Add a button to the action row. Can only be used if the action
252253
row holds message components.
@@ -278,6 +279,11 @@ def add_button(
278279
The label of the button, if any.
279280
emoji: Optional[Union[:class:`.PartialEmoji`, :class:`.Emoji`, :class:`str`]]
280281
The emoji of the button, if available.
282+
sku_id: Optional[:class:`int`]
283+
The ID of a purchasable SKU, for premium buttons.
284+
Premium buttons additionally cannot have a ``label``, ``url``, or ``emoji``.
285+
286+
.. versionadded:: 2.11
281287
282288
Raises
283289
------
@@ -293,6 +299,7 @@ def add_button(
293299
custom_id=custom_id,
294300
url=url,
295301
emoji=emoji,
302+
sku_id=sku_id,
296303
),
297304
)
298305
return self

disnake/ui/button.py

+40-10
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ class Button(Item[V_co]):
5353
The style of the button.
5454
custom_id: Optional[:class:`str`]
5555
The ID of the button that gets received during an interaction.
56-
If this button is for a URL, it does not have a custom ID.
56+
If this button is for a URL or an SKU, it does not have a custom ID.
5757
url: Optional[:class:`str`]
5858
The URL this button sends you to.
5959
disabled: :class:`bool`
@@ -62,6 +62,11 @@ class Button(Item[V_co]):
6262
The label of the button, if any.
6363
emoji: Optional[Union[:class:`.PartialEmoji`, :class:`.Emoji`, :class:`str`]]
6464
The emoji of the button, if available.
65+
sku_id: Optional[:class:`int`]
66+
The ID of a purchasable SKU, for premium buttons.
67+
Premium buttons additionally cannot have a ``label``, ``url``, or ``emoji``.
68+
69+
.. versionadded:: 2.11
6570
row: Optional[:class:`int`]
6671
The relative row this button belongs to. A Discord component can only have 5
6772
rows. By default, items are arranged automatically into those 5 rows. If you'd
@@ -76,6 +81,7 @@ class Button(Item[V_co]):
7681
"disabled",
7782
"label",
7883
"emoji",
84+
"sku_id",
7985
"row",
8086
)
8187
# We have to set this to MISSING in order to overwrite the abstract property from WrappedComponent
@@ -91,6 +97,7 @@ def __init__(
9197
custom_id: Optional[str] = None,
9298
url: Optional[str] = None,
9399
emoji: Optional[Union[str, Emoji, PartialEmoji]] = None,
100+
sku_id: Optional[int] = None,
94101
row: Optional[int] = None,
95102
) -> None: ...
96103

@@ -104,6 +111,7 @@ def __init__(
104111
custom_id: Optional[str] = None,
105112
url: Optional[str] = None,
106113
emoji: Optional[Union[str, Emoji, PartialEmoji]] = None,
114+
sku_id: Optional[int] = None,
107115
row: Optional[int] = None,
108116
) -> None: ...
109117

@@ -116,18 +124,23 @@ def __init__(
116124
custom_id: Optional[str] = None,
117125
url: Optional[str] = None,
118126
emoji: Optional[Union[str, Emoji, PartialEmoji]] = None,
127+
sku_id: Optional[int] = None,
119128
row: Optional[int] = None,
120129
) -> None:
121130
super().__init__()
122-
if custom_id is not None and url is not None:
123-
raise TypeError("cannot mix both url and custom_id with Button")
124131

125132
self._provided_custom_id = custom_id is not None
126-
if url is None and custom_id is None:
133+
mutually_exclusive = 3 - (custom_id, url, sku_id).count(None)
134+
135+
if mutually_exclusive == 0:
127136
custom_id = os.urandom(16).hex()
137+
elif mutually_exclusive != 1:
138+
raise TypeError("cannot mix url, sku_id and custom_id with Button")
128139

129140
if url is not None:
130141
style = ButtonStyle.link
142+
if sku_id is not None:
143+
style = ButtonStyle.premium
131144

132145
if emoji is not None:
133146
if isinstance(emoji, str):
@@ -147,6 +160,7 @@ def __init__(
147160
label=label,
148161
style=style,
149162
emoji=emoji,
163+
sku_id=sku_id,
150164
)
151165
self.row = row
152166

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

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

243+
@property
244+
def sku_id(self) -> Optional[int]:
245+
"""Optional[:class:`int`]: The ID of a purchasable SKU, for premium buttons.
246+
247+
.. versionadded:: 2.11
248+
"""
249+
return self._underlying.sku_id
250+
251+
@sku_id.setter
252+
def sku_id(self, value: Optional[int]) -> None:
253+
if value is not None and not isinstance(value, int):
254+
raise TypeError("sku_id must be None or int")
255+
self._underlying.sku_id = value
256+
229257
@classmethod
230258
def from_component(cls, button: ButtonComponent) -> Self:
231259
return cls(
@@ -235,6 +263,7 @@ def from_component(cls, button: ButtonComponent) -> Self:
235263
custom_id=button.custom_id,
236264
url=button.url,
237265
emoji=button.emoji,
266+
sku_id=button.sku_id,
238267
row=None,
239268
)
240269

@@ -244,6 +273,8 @@ def is_dispatchable(self) -> bool:
244273
def is_persistent(self) -> bool:
245274
if self.style is ButtonStyle.link:
246275
return self.url is not None
276+
elif self.style is ButtonStyle.premium:
277+
return self.sku_id is not None
247278
return super().is_persistent()
248279

249280
def refresh_component(self, button: ButtonComponent) -> None:
@@ -279,11 +310,10 @@ def button(
279310
280311
.. note::
281312
282-
Buttons with a URL cannot be created with this function.
283-
Consider creating a :class:`Button` manually instead.
284-
This is because buttons with a URL do not have a callback
285-
associated with them since Discord does not do any processing
286-
with it.
313+
Link/Premium buttons cannot be created with this function,
314+
since these buttons do not have a callback associated with them.
315+
Consider creating a :class:`Button` manually instead, and adding it
316+
using :meth:`View.add_item`.
287317
288318
Parameters
289319
----------

disnake/ui/view.py

+15-10
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,9 @@
3232
UserSelectMenu as UserSelectComponent,
3333
_component_factory,
3434
)
35-
from ..enums import ComponentType, try_enum_to_int
35+
from ..enums import try_enum_to_int
3636
from ..utils import assert_never
37+
from .button import Button
3738
from .item import Item
3839

3940
__all__ = ("View",)
@@ -412,7 +413,7 @@ def _dispatch_item(self, item: Item, interaction: MessageInteraction) -> None:
412413
)
413414

414415
def refresh(self, components: List[ActionRowComponent[MessageComponent]]) -> None:
415-
# TODO: this is pretty hacky at the moment
416+
# TODO: this is pretty hacky at the moment, see https://github.com/DisnakeDev/disnake/commit/9384a72acb8c515b13a600592121357e165368da
416417
old_state: Dict[Tuple[int, str], Item] = {
417418
(item.type.value, item.custom_id): item # type: ignore
418419
for item in self.children
@@ -424,21 +425,24 @@ def refresh(self, components: List[ActionRowComponent[MessageComponent]]) -> Non
424425
try:
425426
older = old_state[(component.type.value, component.custom_id)] # type: ignore
426427
except (KeyError, AttributeError):
427-
# workaround for url buttons, since they're not part of `old_state`
428+
# workaround for non-interactive buttons, since they're not part of `old_state`
428429
if isinstance(component, ButtonComponent):
429430
for child in self.children:
431+
if not isinstance(child, Button):
432+
continue
433+
# try finding the corresponding child in this view based on other attributes
430434
if (
431-
child.type is ComponentType.button
432-
and child.label == component.label # type: ignore
433-
and child.url == component.url # type: ignore
434-
):
435+
(child.label and child.label == component.label)
436+
and (child.url and child.url == component.url)
437+
) or (child.sku_id and child.sku_id == component.sku_id):
435438
older = child
436439
break
437440

438441
if older:
439-
older.refresh_component(component)
442+
older.refresh_component(component) # type: ignore # this is fine, pyright is trying to be smart
440443
children.append(older)
441444
else:
445+
# fallback, should not happen as long as implementation covers all cases
442446
children.append(_component_to_item(component))
443447

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

0 commit comments

Comments
 (0)