Reaction roles (Go)
Gives members a role when they react to a message with the matching emoji and takes it away when they remove the reaction.
Go 1.23
♥ 0 likes
Environment variables you'll need
| Key | What it is |
|---|---|
DISCORD_TOKEN | Your bot's token from the Discord Developer Portal. Paste it on the Secrets tab; we store it encrypted. |
REACTION_ROLES | Pairs of emoji=role id, separated by commas, e.g. 👍=123456789,🎮=987654321 |
ROLE_MESSAGE_ID | The message to watch. /reactionroles-setup posts one and prints its id |
Files
README.md
# Reaction roles
A Go bot built on discordgo. Your bot token is set on the Secrets tab as
DISCORD_TOKEN and reaches the bot as an environment variable.
## Commands
- `/reactionroles-setup` (needs Manage Roles) posts a message listing the pairs, adds the reactions and prints the message id. Put that id into ROLE_MESSAGE_ID on the Secrets tab and restart the bot.
- Custom emoji work by name or id, e.g. `party=123` or `<:party:111>=123`.
- The bot's highest role must be above every role it hands out.
## Setup
1. No privileged gateway intents are needed. Leave Message Content, Server Members
and Presence off in the Discord Developer Portal.
2. Invite the bot with the `bot` and `applications.commands` scopes and these
permissions: Manage Roles, Add Reactions, Send Messages, Read Message History.
3. Set these values on the Secrets tab. The ones marked optional can stay empty:
- `REACTION_ROLES`: Pairs of emoji=role id, separated by commas, e.g. 👍=123456789,🎮=987654321.
- `ROLE_MESSAGE_ID`: The message to watch. /reactionroles-setup posts one and prints its id.
4. Press Deploy. The console shows a line with "Logged in as" once the bot is online.
go.mod
module example.com/reaction-roles-go
go 1.23
require github.com/bwmarrin/discordgo v0.28.1
go.sum
main.go
package main
import (
"log"
"os"
"os/signal"
"regexp"
"strings"
"syscall"
"github.com/bwmarrin/discordgo"
)
type pair struct {
original string
roleID string
}
var (
digits = regexp.MustCompile(`^\d+$`)
customEmoji = regexp.MustCompile(`^<a?:(\w+):(\d+)>$`)
roleMessageID = strings.TrimSpace(os.Getenv("ROLE_MESSAGE_ID"))
keys, pairs = parsePairs(os.Getenv("REACTION_ROLES"))
manageRoles = int64(discordgo.PermissionManageRoles)
noDM = false
)
var commands = []*discordgo.ApplicationCommand{{
Name: "reactionroles-setup", Description: "Post the reaction roles message in this channel",
DefaultMemberPermissions: &manageRoles, DMPermission: &noDM,
}}
// normalize turns <:name:id> into the id and strips U+FE0F from unicode emoji.
func normalize(key string) string {
key = strings.TrimSpace(key)
if m := customEmoji.FindStringSubmatch(key); m != nil {
return m[2]
}
return strings.ReplaceAll(key, string(rune(0xFE0F)), "")
}
func parsePairs(raw string) ([]string, map[string]pair) {
var order []string
out := map[string]pair{}
for _, item := range strings.Split(raw, ",") {
at := strings.LastIndex(item, "=")
if at < 0 {
continue
}
emoji, roleID := strings.TrimSpace(item[:at]), strings.TrimSpace(item[at+1:])
if emoji == "" || !digits.MatchString(roleID) {
log.Printf("skipping bad REACTION_ROLES entry: %q", item)
continue
}
key := normalize(emoji)
if _, seen := out[key]; !seen {
order = append(order, key)
}
out[key] = pair{emoji, roleID}
}
return order, out
}
// roleFor matches a reaction by custom emoji id, custom emoji name, or unicode text.
func roleFor(e discordgo.Emoji) string {
for _, key := range []string{e.ID, normalize(e.Name)} {
if p, ok := pairs[key]; ok && key != "" {
return p.roleID
}
}
return ""
}
func isBot(s *discordgo.Session, guildID, userID string, member *discordgo.Member) bool {
if member != nil && member.User != nil {
return member.User.Bot
}
if m, err := s.State.Member(guildID, userID); err == nil && m.User != nil {
return m.User.Bot
}
u, err := s.User(userID)
return err != nil || u.Bot
}
func updateRole(s *discordgo.Session, r *discordgo.MessageReaction, member *discordgo.Member, add bool) {
if r.GuildID == "" || roleMessageID == "" || r.MessageID != roleMessageID || r.UserID == s.State.User.ID {
return
}
roleID := roleFor(r.Emoji)
if roleID == "" || isBot(s, r.GuildID, r.UserID, member) {
return
}
if _, err := s.State.Role(r.GuildID, roleID); err != nil {
log.Printf("role %s for %s does not exist in this server", roleID, r.Emoji.Name)
return
}
var err error
if add {
err = s.GuildMemberRoleAdd(r.GuildID, r.UserID, roleID, discordgo.WithAuditLogReason("Reaction role"))
} else {
err = s.GuildMemberRoleRemove(r.GuildID, r.UserID, roleID, discordgo.WithAuditLogReason("Reaction role"))
}
if err != nil {
log.Printf("could not update role %s: %v (is my role above it?)", roleID, err)
}
}
// resolveEmoji returns the reaction API name and a display form, or "" if unknown.
func resolveEmoji(s *discordgo.Session, guildID, key, original string) (string, string) {
guild, err := s.State.Guild(guildID)
if err == nil {
for _, e := range guild.Emojis {
if e.ID == key || e.Name == key {
return e.APIName(), e.MessageFormat()
}
}
}
if digits.MatchString(key) {
return "", original // a custom emoji id from another server
}
return original, original
}
func setup(s *discordgo.Session, i *discordgo.InteractionCreate) error {
if len(keys) == 0 {
return s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{Flags: discordgo.MessageFlagsEphemeral,
Content: "REACTION_ROLES is empty. Set it in the Secrets tab first."}})
}
// posting and reacting can take longer than the 3 second reply window
err := s.InteractionRespond(i.Interaction, &discordgo.InteractionRespons