TutorialsDiscord Slash Command Tutorial

Discord Slash Command Tutorial

This tutorial demonstrates how to create a Discord bot with slash commands that can interact with your GTPS server. We’ll build a player punishment system with interactive buttons.

Overview

This example shows:

  • Registering Discord slash commands when the bot is ready
  • Handling slash command invocations
  • User input validation and player lookup
  • Interactive buttons for ban/unban actions
  • Ephemeral “thinking” states for better UX

Full Example Script

-- discord-example script
print("(Loaded) discord-example script for GTPS Cloud")
 
function findMatch(avList, searchName)
    for _, player in ipairs(avList) do
        if player:getCleanName():lower() == searchName:lower() then
            return {player}
        end
    end
    return nil
end
 
onDiscordBotReadyCallback(function()
    print("Discord bot is ready")
 
    DiscordBot.globalCommandCreate("punish", "Punish player", {
        { type="string", name="player_name", description="Player name", required=true }
    })
end)
 
onDiscordSlashCommandCallback(function(e)
    local player = e:getPlayer() -- If user has acc linked it will return valid GTPS player
    local commandName = e:getCommandName()
    local channelID = e:getChannelID()
    local authorID = e:getAuthorID()
 
    print("Received slash command event, cName: " .. commandName .. " cId: " .. channelID .. " aId: " .. authorID)
 
    if commandName == "punish" then
        e:thinking() -- Use e:thinking(1) to make it ephemeral, thinking is not required at all
        -- If u dont want to use e:thinking then use e:reply() instead of e:editOriginalResponse() below.
 
        local playerName = e:getParameter("player_name")
 
        if playerName == "" then
            e:editOriginalResponse(channelID, "Oops: You need to enter player name.")
            return
        end
 
        local foundPlayers = getPlayerByName(playerName)
 
        if #foundPlayers == 0 then
            e:editOriginalResponse(channelID, "Oops: There is nobody currently in this server with a name starting with " .. playerName .. ".")
            return
        end
        
        if #foundPlayers > 1 then
            local matchedPlayers = findMatch(foundPlayers, playerName)
            if matchedPlayers then
                foundPlayers = matchedPlayers
            else
                local possible_matches = {}
                local limit = math.min(#foundPlayers, 3)
                
                for i = 1, limit do
                    table.insert(possible_matches, foundPlayers[i]:getName())
                end
 
                local extra_count = #foundPlayers - 3
                local extra_info = extra_count > 0 and (" and " .. formatNum(extra_count) .. " more...") or "."
 
                e:editOriginalResponse(channelID, "Error, more than one person's name in this server starts with " .. playerName .. ". Be more specific. Possible matches: " .. table.concat(possible_matches, ", ") .. extra_info)
 
                return
            end
        end
 
        local targetPlayer = foundPlayers[1]
 
        local isBanned = targetPlayer:hasMod(-75)
 
        local button
 
        if isBanned then
            button = {
                type = "button",
                label = "Unban",
                style = "success",
                emoji = "😇",
                id = "unban_usr_" .. targetPlayer:getUserID()
            }
        else
            button = {
                type = "button",
                label = "Ban 60 days",
                style = "danger",
                emoji = "😭",
                id = "ban_usr_" .. targetPlayer:getUserID()
            }
        end
 
        e:editOriginalResponse(
            channelID,
            "**" .. targetPlayer:getCleanName() .. " (ID " .. targetPlayer:getUserID() .. ") - Banned = " .. tostring(isBanned) .. "**",
            { button }
        )
    end
end)
 
onDiscordButtonClickCallback(function(e)
    local customID = e:getCustomID()
 
    if string.sub(customID, 1, 8) == "ban_usr_" then
        local userID = tonumber(string.sub(customID, 9))
        local player = getPlayer(userID)
 
        player:addMod(-75, 60 * 24 * 60 * 60)
 
        e:reply("Player **" .. player:getCleanName() .. "** has been banned for 60 days. 😭")
    elseif string.sub(customID, 1, 10) == "unban_usr_" then
        local userID = tonumber(string.sub(customID, 11))
        local player = getPlayer(userID)
 
        player:removeMod(-75)
 
        e:reply("Player **" .. player:getCleanName() .. "** has been unbanned. 😇")
    end
end)

Breaking Down the Code

1. Helper Function - Exact Name Match

function findMatch(avList, searchName)
    for _, player in ipairs(avList) do
        if player:getCleanName():lower() == searchName:lower() then
            return {player}
        end
    end
    return nil
end

This function finds an exact match for a player name (case-insensitive). It’s used when multiple players have similar names.

2. Bot Ready Callback

onDiscordBotReadyCallback(function()
    print("Discord bot is ready")
 
    DiscordBot.globalCommandCreate("punish", "Punish player", {
        { type="string", name="player_name", description="Player name", required=true }
    })
end)

Key Points:

  • This callback fires when your Discord bot comes online
  • Perfect place to register slash commands
  • Commands are registered globally and will appear in Discord
  • Parameter types: string, integer, boolean, user, channel, role

3. Slash Command Handler

onDiscordSlashCommandCallback(function(e)
    local player = e:getPlayer() -- Linked GTPS player (if available)
    local commandName = e:getCommandName()
    local channelID = e:getChannelID()
    local authorID = e:getAuthorID()

Available Methods:

  • e:getPlayer() - Returns GTPS player if Discord account is linked
  • e:getCommandName() - The slash command name that was invoked
  • e:getParameter(name) - Get parameter values
  • e:thinking() - Show “Bot is thinking…” message
  • e:thinking(1) - Show ephemeral (only visible to user) thinking message
  • e:editOriginalResponse() - Edit the response after thinking
  • e:reply() - Send immediate reply (use instead of thinking + edit)

4. Player Lookup & Validation

local playerName = e:getParameter("player_name")
 
if playerName == "" then
    e:editOriginalResponse(channelID, "Oops: You need to enter player name.")
    return
end
 
local foundPlayers = getPlayerByName(playerName)
 
if #foundPlayers == 0 then
    e:editOriginalResponse(channelID, "Oops: There is nobody currently in this server with a name starting with " .. playerName .. ".")
    return
end

Validation Steps:

  1. Get parameter value
  2. Check if empty
  3. Search for players
  4. Handle “not found” case
  5. Handle multiple matches case

5. Multiple Match Handling

if #foundPlayers > 1 then
    local matchedPlayers = findMatch(foundPlayers, playerName)
    if matchedPlayers then
        foundPlayers = matchedPlayers
    else
        -- Show possible matches
        local possible_matches = {}
        local limit = math.min(#foundPlayers, 3)
        
        for i = 1, limit do
            table.insert(possible_matches, foundPlayers[i]:getName())
        end
 
        local extra_count = #foundPlayers - 3
        local extra_info = extra_count > 0 and (" and " .. formatNum(extra_count) .. " more...") or "."
 
        e:editOriginalResponse(channelID, "Error, more than one person's name in this server starts with " .. playerName .. ". Be more specific. Possible matches: " .. table.concat(possible_matches, ", ") .. extra_info)
        return
    end
end

Logic:

  1. If multiple players found, try exact match first
  2. If no exact match, show up to 3 suggestions
  3. Tell user how many more matches exist

6. Dynamic Button Generation

local targetPlayer = foundPlayers[1]
local isBanned = targetPlayer:hasMod(-75)
 
local button
 
if isBanned then
    button = {
        type = "button",
        label = "Unban",
        style = "success",
        emoji = "😇",
        id = "unban_usr_" .. targetPlayer:getUserID()
    }
else
    button = {
        type = "button",
        label = "Ban 60 days",
        style = "danger",
        emoji = "😭",
        id = "ban_usr_" .. targetPlayer:getUserID()
    }
end

Button Styles:

  • primary - Blue (default action)
  • success - Green (positive action)
  • danger - Red (destructive action)
  • secondary - Gray (neutral action)

Important: The id field encodes the user ID so the button callback knows which player to target.

7. Button Click Handler

onDiscordButtonClickCallback(function(e)
    local customID = e:getCustomID()
 
    if string.sub(customID, 1, 8) == "ban_usr_" then
        local userID = tonumber(string.sub(customID, 9))
        local player = getPlayer(userID)
 
        player:addMod(-75, 60 * 24 * 60 * 60)
 
        e:reply("Player **" .. player:getCleanName() .. "** has been banned for 60 days. 😭")
    elseif string.sub(customID, 1, 10) == "unban_usr_" then
        local userID = tonumber(string.sub(customID, 11))
        local player = getPlayer(userID)
 
        player:removeMod(-75)
 
        e:reply("Player **" .. player:getCleanName() .. "** has been unbanned. 😇")
    end
end)

Key Techniques:

  • Extract user ID from button’s custom ID
  • Use string.sub() to parse the ID format
  • Apply mod (punishment) with duration in seconds
  • Send confirmation reply

Usage

  1. Save this script to your server’s Lua scripts folder
  2. Restart your server or reload scripts
  3. Wait for the bot to come online (check console for “Discord bot is ready”)
  4. In Discord, type /punish and you’ll see the command
  5. Enter a player name and press Enter
  6. Click the Ban/Unban button to apply the action

Extending This Example

Add More Commands:

onDiscordBotReadyCallback(function()
    DiscordBot.globalCommandCreate("punish", "Punish player", {
        { type="string", name="player_name", description="Player name", required=true }
    })
    
    DiscordBot.globalCommandCreate("kick", "Kick player", {
        { type="string", name="player_name", description="Player name", required=true }
    })
    
    DiscordBot.globalCommandCreate("give", "Give items to player", {
        { type="string", name="player_name", description="Player name", required=true },
        { type="integer", name="item_id", description="Item ID", required=true },
        { type="integer", name="amount", description="Amount", required=true }
    })
end)

Add Permission Checks:

onDiscordSlashCommandCallback(function(e)
    local authorID = e:getAuthorID()
    
    -- Check if user has admin role (replace with your role ID)
    if not hasDiscordRole(authorID, "1234567890123456789") then
        e:reply("You don't have permission to use this command!", nil, ReplyFlags.EPHEMERAL)
        return
    end
    
    -- ... rest of command logic
end)

Notes

  • Component structure now uses rows (old flat format doesn’t work)
  • Use e:thinking() for commands that take time to process
  • Use e:reply() for instant responses
  • Custom IDs should encode necessary data for the button callback
  • Ban duration is in seconds: 60 * 24 * 60 * 60 = 60 days

Discord Integration Complete

You now have a working Discord bot that can manage players through slash commands and interactive buttons!