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

Environment variables you'll need

KeyWhat it is
DISCORD_TOKENYour bot's token from the Discord Developer Portal. Paste it on the Secrets tab; we store it encrypted.
REACTION_ROLESPairs of emoji=role id, separated by commas, e.g. 👍=123456789,🎮=987654321
ROLE_MESSAGE_IDThe message to watch. /reactionroles-setup posts one and prints its id

Create an account to use it

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