Moderation bot (Go)

Kick, ban and timeout slash commands that check both your roles and the bot's before acting.

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.
MOD_LOG_CHANNEL_IDOptional. Channel where every action is logged

Create an account to use it

Files

README.md

# Moderation bot

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

- `/kick member [reason]`
- `/ban member [reason] [delete_days 0-7]`
- `/timeout member minutes [reason]` (up to 28 days)
- Each command checks that you have the permission, that the target is not you, the server owner or the bot, and that your highest role and the bot's highest role are both above the target's. Server owners skip the check on their own roles.
- Server admins can change who sees the commands under Server Settings, Integrations.

## 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: Kick Members, Ban Members, Moderate Members, Send Messages.
3. Set these values on the Secrets tab. The ones marked optional can stay empty:

- `MOD_LOG_CHANNEL_ID`: Optional. Channel where every action is logged.

4. Press Deploy. The console shows a line with "Logged in as" once the bot is online.

go.mod

module example.com/moderation-bot-go

go 1.23

require github.com/bwmarrin/discordgo v0.28.1

go.sum

main.go

package main

import (
	"fmt"
	"log"
	"os"
	"os/signal"
	"regexp"
	"strings"
	"syscall"
	"time"

	"github.com/bwmarrin/discordgo"
)

const maxTimeoutMinutes = 28 * 24 * 60 // Discord's limit is 28 days

var (
	modLogChannelID       = strings.TrimSpace(os.Getenv("MOD_LOG_CHANNEL_ID"))
	digits                = regexp.MustCompile(`^\d+$`)
	noDM                  = false
	zero                  = 0.0
	one                   = 1.0
	kickPerm        int64 = discordgo.PermissionKickMembers
	banPerm         int64 = discordgo.PermissionBanMembers
	modPerm         int64 = discordgo.PermissionModerateMembers
)

type action struct {
	perm  int64
	label string
}

var actions = map[string]action{
	"kick":    {kickPerm, "Kick Members"},
	"ban":     {banPerm, "Ban Members"},
	"timeout": {modPerm, "Timeout Members"},
}

func opts(verb string, extra ...*discordgo.ApplicationCommandOption) []*discordgo.ApplicationCommandOption {
	list := []*discordgo.ApplicationCommandOption{{Type: discordgo.ApplicationCommandOptionUser,
		Name: "member", Description: "Member to " + verb, Required: true}}
	list = append(list, extra...)
	return append(list, &discordgo.ApplicationCommandOption{Type: discordgo.ApplicationCommandOptionString,
		Name: "reason", Description: "Reason for the audit log"})
}

var commands = []*discordgo.ApplicationCommand{
	{Name: "kick", Description: "Kick a member", DefaultMemberPermissions: &kickPerm, DMPermission: &noDM,
		Options: opts("kick")},
	{Name: "ban", Description: "Ban a member", DefaultMemberPermissions: &banPerm, DMPermission: &noDM,
		Options: append(opts("ban"), &discordgo.ApplicationCommandOption{Type: discordgo.ApplicationCommandOptionInteger,
			Name: "delete_days", Description: "Days of their messages to delete (0 to 7)", MinValue: &zero, MaxValue: 7})},
	{Name: "timeout", Description: "Time out a member", DefaultMemberPermissions: &modPerm, DMPermission: &noDM,
		Options: opts("time out", &discordgo.ApplicationCommandOption{Type: discordgo.ApplicationCommandOptionInteger,
			Name: "minutes", Description: "Length in minutes (max 28 days)", Required: true,
			MinValue: &one, MaxValue: maxTimeoutMinutes})},
}

func reply(s *discordgo.Session, i *discordgo.InteractionCreate, content string) error {
	return s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
		Type: discordgo.InteractionResponseChannelMessageWithSource,
		Data: &discordgo.InteractionResponseData{Content: content, Flags: discordgo.MessageFlagsEphemeral}})
}

func highest(roles []string, positions map[string]int) int {
	top := 0 // @everyone
	for _, id := range roles {
		if p, ok := positions[id]; ok && p > top {
			top = p
		}
	}
	return top
}

// checkTarget returns an error message, or "" when the action is allowed.
func checkTarget(s *discordgo.Session, i *discordgo.InteractionCreate, target *discordgo.Member, targetID string, a action) string {
	actor := i.Member
	if actor.Permissions&(a.perm|discordgo.PermissionAdministrator) == 0 {
		return "You need the " + a.label + " permission to do that."
	}
	guild, err := s.State.Guild(i.GuildID)
	if err != nil {
		return "I could not load this server. Try again in a moment."
	}
	botID := s.State.User.ID
	if perms, err := s.State.UserChannelPermissions(botID, i.ChannelID); err == nil &&
		perms&(a.perm|discordgo.PermissionAdministrator) == 0 {
		return "I need the " + a.label + " permission to do that."
	}
	switch {
	case target == nil:
		return "That user is not a member of this server."
	case targetID == actor.User.ID:
		return "You cannot use this on yourself."
	case targetID == guild.OwnerID:
		return "You cannot use this on the server owner."
	case targetID == botID:
		return "I cannot use this on myself."
	}
	positions := map[string]int{}
	for _, r := range guild.Roles {
		positions[r.ID] = r.Position
	}
	targetTop := highest(target.Roles, positions)
	if actor.User.ID != guild.OwnerID && highest(actor.Roles, positions) <= targetTop {
		return "Your highest role must be above the target's hig