Skip to content
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

feat: Added an explanation on reactions #55

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
299 changes: 297 additions & 2 deletions guide/docs/popular-topics/reactions.mdx
Original file line number Diff line number Diff line change
@@ -1,8 +1,303 @@
---
description: Create polls, paginate your commands, and more.
description: This section covers the topic of interacting with message reactions. Both adding them with code, and reacting to on_reaction events
hide_table_of_contents: true
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TOC will be visible on non-WIP pages.

Suggested change
hide_table_of_contents: true

---

# Reactions

<WorkInProgress />
Reactions is discords way of adding emojis to other messages. Early on, before discord introduced components; this system was largely used to make interactive messages and apps.
Strixen marked this conversation as resolved.
Show resolved Hide resolved
Having bots react to message reactions is less common now, and is somewhat considered legacy behaviour.
This guide will teach you the basics of how they work, since they still have their use cases.
Mainly reaction role systems and polling, but there are some other uses.
Strixen marked this conversation as resolved.
Show resolved Hide resolved

In Disnake reactions are represented with <DocsLink reference="disnake.Reaction">Reaction</DocsLink> objects. Whenever you operate on a <DocsLink reference="disnake.Message">Message</DocsLink> you can access a list of reactions attached to that message.
In this guide we will be providing an example using the <DocsLink reference="disnake.on_raw_reaction_add">on_raw_reaction_add / remove</DocsLink> events and a message_command's <DocsLink reference="disnake.MessageCommandInteraction">interaction</DocsLink> to demonstrate.
Strixen marked this conversation as resolved.
Show resolved Hide resolved

:::info
**Reaction limitations**

- Removing reactions that are not owned by the bot requires <DocsLink reference="disnake.Intents.reactions">Intents.reactions</DocsLink> to be set
shiftinv marked this conversation as resolved.
Show resolved Hide resolved
- Therefore <DocsLink reference="disnake.Intents.messages">Intents.messages</DocsLink> is indirectly required if you want to manipulate reactions
Strixen marked this conversation as resolved.
Show resolved Hide resolved
- A message can have a maximum of 20 unique reactions on it at one time.
- Reactions are inherently linked to emojis, and your bot will not have access to resend all emojis used by discord users
- Dealing with reactions result in a fair amount of extra api-calls, meaning it can have rate-limit implications on deployment scale.
- Using Reactions as a UX interface was never a inteded behavior, and is ultimatly inferior to the newer component style interface
Strixen marked this conversation as resolved.
Show resolved Hide resolved
:::
Strixen marked this conversation as resolved.
Show resolved Hide resolved

<DiscordMessages>
<DiscordMessage profile="user">
In case you are unaware, reactions are the emojis below this message.
<br />
Emojis that are highlighted means you've reacted to it, and the number indicates how many have reacted to it.
<div slot="actions">
<DiscordReactions>
<DiscordReaction
name="disnake_pride"
image="https://cdn.discordapp.com/emojis/983256241142910996.webp?size=96&quality=lossless"
count={3}
active={true}
/>
<DiscordReaction
name="disnake"
image="https://cdn.discordapp.com/emojis/922937443039186975.webp?size=96&quality=lossless"
count={1}
/>
</DiscordReactions>
</div>
</DiscordMessage>
</DiscordMessages>

### Emojis

Since reactions utilize Emojis this guide will also include a quick primer on how disnake handles emojis
Emojis have three forms:

- <DocsLink reference="disnake.Emoji">Emoji</DocsLink> Custom emojis
- <DocsLink reference="disnake.PartialEmoji">PartialEmoji</DocsLink> Stripped down version of Emoji
- [`string`](https://docs.python.org/3/library/string.html) String containing one or more emoji unicodepoints (Emoji modifiers complicates things but thats out of scope)
shiftinv marked this conversation as resolved.
Show resolved Hide resolved

**Which one you get is circumstancial:**
Emoji class: is primarely returned when custom emojis are grabbed from the guild/bot
PartialEmoji: are most often custom emojis too, but will usually represent custom emojis the bot can't access
Strings: are normally returned when Unicode CodePoints are used. These are the standard emojis most are familiar with (✅🎮💛💫)
but these can also come as a PartialEmoji
Strixen marked this conversation as resolved.
Show resolved Hide resolved

There is also a small write up about this [`here`](//faq/general.mdx#how-can-i-add-a-reaction-to-a-message)
Strixen marked this conversation as resolved.
Show resolved Hide resolved

:::note
The examples are only meant to demonstrate how disnake interacts with Reactions, and should probably not be copied verbatim.
These examples are not intended for cogs, but can easily be adapted to run inside one. See: [**Creating cogs/extensions**](//getting-started/using-cogs.mdx)
Some examples are also available in the [`DisnakeDev`](https://github.com/DisnakeDev/disnake/tree/master/examples) github repository
:::
Strixen marked this conversation as resolved.
Show resolved Hide resolved

### Example using on_reaction events

There are a few reaction related events we can listen/subscribe to:

- <DocsLink reference="disnake.on_raw_reaction_add">on_raw_reaction_add</DocsLink>
- <DocsLink reference="disnake.on_raw_reaction_remove">on_raw_reaction_remove</DocsLink>
- <DocsLink reference="disnake.on_raw_reaction_clear">on_raw_reaction_clear</DocsLink> Called when a message has all reactions
removed
- <DocsLink reference="disnake.on_raw_reaction_clear_emoji">on_raw_reaction_clear_emoji</DocsLink> Called when a specific
reaction is removed from a message{' '}
Strixen marked this conversation as resolved.
Show resolved Hide resolved

There are non-raw equivilants, but they rely on the cache. If the message is not found in the internal cache, then the event is not called.
For this reason raw events are preffered, and you are only giving up on an included User/Member object that you can easily fetch if you need it.
Strixen marked this conversation as resolved.
Show resolved Hide resolved

- More information about events can be found in the docs, [`here`](https://docs.disnake.dev/en/stable/api.html#event-reference)
shiftinv marked this conversation as resolved.
Show resolved Hide resolved

One important thing about raw_reaction events is that all the payloads are only populated with <DocsLink reference="disnake.PartialEmoji">PartialEmojis</DocsLink>
This is generally not an issue since it contains everything we need, but its something you should be aware of.
Raw reaction events come a <DocsLink reference="disnake.RawReactionActionEvent">RawReactionActionEvent</DocsLink> which is called `payload` in the examples.
shiftinv marked this conversation as resolved.
Show resolved Hide resolved

```python title="on_raw_reaction_add.py"
import disnake


Strixen marked this conversation as resolved.
Show resolved Hide resolved
@bot.listen()
async def on_raw_reaction_add(self, payload: disnake.RawReactionActionEvent):
# For this example we will have the bot post a message describing the event, and adding the emoji to that message as an exercise

# We don't want the bot to react to its own actions, nor DM's in this case
if payload.user_id == bot.user.id:
return
if not payload.guild_id:
return # guild_id is None if its a DM

# Raw event's contain few objects, so we need to grab the channel from the cache
Strixen marked this conversation as resolved.
Show resolved Hide resolved
event_channel = bot.get_channel(payload.channel_id)

# With the channel in hand we can use it to post a new message like normal, Messageable.send() returns the message, and we need to store it
event_response_message = await event_channel.send(
content=f"Reaction {payload.emoji} added by: {payload.member.display_name}!"
)

# Now using that stored message, we can add our own reaction to it, and the add_reaction() coroutine supports PartialEmojis so we're good to go
# One thing we need to consider is that the bot cannot access custom emojis outside servers they occupy (see caution below)
# Because of this we need to check if we have access to the custom_emoji.
# disnake.Emoji have a is_usable() function we can reference, but Partials do not so we need to check manually.
if payload.emoji.is_custom_emoji and not bot.get_emoji(payload.emoji.id):
return # The emoji is custom, but could not be found in the cache.
await event_response_message.add_reaction(payload.emoji)
Strixen marked this conversation as resolved.
Show resolved Hide resolved
```

Below is how the the listener above would react both for a Unicode CodePoint emoji and a custom_emoji the bot can't access
Notice how the **payload.emoji** resolved into **:disnake:** because the emoji is on a server not accessable to the bot
Strixen marked this conversation as resolved.
Show resolved Hide resolved

<DiscordMessages>
<DiscordMessage profile="user">
Join the Disnake Discord server, it's an amazing community
<div slot="actions">
<DiscordReactions>
<DiscordReaction
name="popcorn"
image="https://emojipedia-us.s3.dualstack.us-west-1.amazonaws.com/thumbs/160/microsoft/310/popcorn_1f37f.png"
count={1}
/>
<DiscordReaction
name="disnake"
image="https://cdn.discordapp.com/emojis/922937443039186975.webp?size=96&quality=lossless"
count={1}
/>
</DiscordReactions>
</div>
</DiscordMessage>
<DiscordMessage profile="bot">
Reaction 🍿 added by: AbhigyanTrips!
<div slot="actions">
<DiscordReactions>
<DiscordReaction
name="popcorn"
image="https://emojipedia-us.s3.dualstack.us-west-1.amazonaws.com/thumbs/160/microsoft/310/popcorn_1f37f.png"
count={1}
/>
</DiscordReactions>
</div>
</DiscordMessage>
<DiscordMessage profile="bot">Reaction :disnake: added by: AbhigyanTrips!</DiscordMessage>
</DiscordMessages>{' '}

:::caution
Strixen marked this conversation as resolved.
Show resolved Hide resolved
We can only use custom emojis from servers the bot has joined, but we can use them interchangably on those servers.
Bots can make <DocsLink reference="disnake.ui.Button">buttons</DocsLink> using emojis from outside servers they occupy, this may or may not be intended behaviour from Discord and should not be relied on.
Strixen marked this conversation as resolved.
Show resolved Hide resolved
:::

Here's a few ways you could filter on reactions to do various things

```python title=react_actions.py
shiftinv marked this conversation as resolved.
Show resolved Hide resolved
import disnake

# These lists are arbitrary and is just to provide context. Using static lists like this can be ok in small bots, but should really be supplied by a db.
allowed_emojis = ["💙"]
button_emojis = ["✅"]
restricted_role_ids = [951263965235773480, 1060778008039919616]
reaction_messages = [1060797825417478154]
reaction_roles = {
"🎮": 1060778008039919616,
"🚀": 1007024363616350308,
"<:catpat:967269162386858055>": 1056775021281943583,
}


@bot.listen()
async def on_raw_reaction_add(payload: disnake.RawReactionActionEvent):
# This example is more to illustrate the different ways you can filter which emoji's are used and then you can do your actions on them
# All the functions in it have been tested, but you should add more checks and returns if you actually wanted all these functions at the same time

Strixen marked this conversation as resolved.
Show resolved Hide resolved
# We usually don't want the bot to react to its own actions, nor DM's in this case
if payload.user_id == bot.user.id:
return
if not payload.guild_id:
return # guild_id is None if its a DM

# Again, getting the channel, and fetching message as these will be useful
event_channel = bot.get_channel(payload.channel_id)
event_message = await event_channel.fetch_message(payload.message_id)

# Members with a restricted role, are only allowed to react with💙 -- From the docs we know that str(PartialEmoji) returns either the codepoint or <:emoji:id>
if [role for role in payload.member.roles if role.id in restricted_role_ids] and not str(
payload.emoji
) in allowed_emojis:
# Since the list did not return empty and is not a allowed emoji, we remove it
await event_message.remove_reaction(emoji=payload.emoji, member=payload.member)

# Similar behavior can be useful if you want to use reactions as buttons. Since you have to un-react and react again to repeat the effect
# This can be usefull if you want the functionality of buttons, but want a more compact look.
# but its also a lot of extra api calls compared to components
if str(payload.emoji) in button_emojis:
# In a proper bot you would do more checks, against message_id most likely.
# As theese reactions might normaly be supplied by the bot in the first place

# Or if the member has the right roles in case the reaction has a moderation function for instance
# Otherwise the awesome_function() can end up going off at wrong places
await event_message.remove_reaction(
emoji=payload.emoji, member=payload.member
) # Remove the reaction
awesome_function()
await event_channel.send("Done!", delete_after=10.0)
# Short message to let the user know it went ok. This is not an interaction so a message response is not strictly needed

# A very simple reaction role system
if str(payload.emoji) in reaction_roles.keys() and payload.message_id in reaction_messages:
role_to_apply = bot.get_guild(payload.guild_id).get_role(reaction_roles[str(payload.emoji)])
if (
role_to_apply and not role_to_apply in payload.member.roles
): # Check if we actually got a role, then check if the member already has it, if not add it
await payload.member.add_roles(role_to_apply)


@bot.listen()
async def on_raw_reaction_remove(payload: disnake.RawReactionActionEvent):
if payload.user_id == bot.user.id:
return
if not payload.guild_id:
return # guild_id is None if its a DM

# Counterpart to the simple reaction role system
if (
str(payload.emoji) in reaction_roles.keys() and payload.message_id in reaction_messages
): # Check that the emoji and message is correct
role_to_remove = bot.get_guild(payload.guild_id).get_role(
reaction_roles[str(payload.emoji)]
)
if (
role_to_apply and role_to_apply in payload.member.roles
): # Check if we actually got a role, then check if the member actually has it, then remove it
await payload.member.remove_roles(role_to_apply)
```

### Example using message_command

We could go with a `slash_command` here, but since we will be targeting other messages, it adds a complication because if the message is in a different channel from where the command is executed; the retrieved message will be `None`.
Using a `message_command` instead side-steps this issue, since the targeted message will be present in the interaction object.
shiftinv marked this conversation as resolved.
Show resolved Hide resolved

- <DocsLink reference="disnake.Reactions.users">disnake.Reactions.users</DocsLink> won't be covered here since the docs
demonstrate its use elegantly.

This example is purely to demonstrate using the Reaction object since events deal with a similar but different class

```python title="message_command.py"
@commands.message_command()
async def list_reactions(self, inter: disnake.MessageCommandInteraction):

# Here's a very pythonic way of making a list of the reactions
response_string = (
"".join(
[
f"{index+1}. {reaction.emoji} - {reaction.count}\n"
for index, reaction in enumerate(inter.target.reactions)
]
)
or "No Reactions found"
)

# Here it is broken up in case list comprehensions are too confusing
response_string = "" # Start with an empty string
reaction_list = inter.target.reactions # First we get the list of disnake.Reaction objects
for index, reaction in enumerate(
reaction_list
): # We then loop through the reactions and use enumerate for indexing
response_string += f"{index+1}. {reaction.emoji} - {reaction.count}\n" # Using f-strings we format the list how we like
shiftinv marked this conversation as resolved.
Show resolved Hide resolved
if (
not response_string
): # If the message has no reactions, response_string will be "" which evaluates as False
response_string = "No Reactions found"

await inter.response.send_message(response_string)
Strixen marked this conversation as resolved.
Show resolved Hide resolved

# As with the previous examples, we can add reactions too

# inter.response.send_message() does not return the message generated so we have to fetch it, thankfully we have this alias we can use
message = await inter.original_response()

for reaction in reaction_list:

# Since the reactions are present on the message, the bot can react to it, even tho it does not have access to the custom emoji
Strixen marked this conversation as resolved.
Show resolved Hide resolved
Strixen marked this conversation as resolved.
Show resolved Hide resolved
await inter.target.add_reaction(reaction)

# However we still cannot add new reactions we don't have access to.
# When listing through reactions PartialEmojis are generated if the bot does not have access to it, so we can filter on that to skip them
if isinstance(reaction.emoji, disnake.PartialEmoji):
continue
await message.add_reaction(reaction)
```