Making a Discord Playlist bot with Serverless Cloud

Mar 4, 2022

In an earlier post, we built a Slack bot for adding shared songs to a central Spotify playlist with Serverless Cloud. Next, we will build a playlister bot for Discord, with the same features and functionality. We will be using Discord’s new feature, Application Commands, which operate very similarly to Slack slash commands. This is a very bare-bones demo of combining the power of Serverless Cloud with Discord’s robust API’s, so let your imagination run with the possibilities here! This guide is only meant to get you started. 

The final code for this application can be found here.

Creating the Cloud App

First things first, we need to get an application up and running to handle our requests. Start off by creating a new empty directory, called "discord-playlister". With your terminal of choice, navigate to this new directory and run the following to initialize a new Cloud application: 

npm init cloud

You may be prompted to login if you haven’t already, then the CLI will ask you to create a new application and select a template to get started with. Choose the JavaScript API or TypeScript API template.

Initializing a new Serverless Cloud app

Making a Discord App

Next, let’s set up a Discord app for your desired Discord server. You will need administrator privileges on your Discord server to install the app. Go to the Discord application directory, and login if you have to. From here, click “New Application” at the top right of the window. Put in a friendly name for the app, I used “Playlister”. Then, click the “Bot” section in the navigation side-panel, and then “Add Bot”. This will allow the app to asynchronously send messaging back to the Discord server text channels. 

Next, navigate to the OAuth2 section of the side-panel. Under “Default Authorization Link”, click “In App Authorization” in the drop down menu. After that, a list of scopes and bot permissions will appear. Click the desired permissions for whatever you may want your bot to be in the future, below is a photo of the permissions I granted mine for its use case.

Now, let’s copy some sensitive ID’s over to Serverless Cloud Params. For all the Discord functionality we will be using, we need a few things. First off is the application’s “Public Key”, this can be found in the “General Information” section in the navigation side bar. Type “open” in your Cloud Shell to automatically navigate to your new application on cloud.serverless.com. Click on the “Params” tab, add a new param, and save the Public Key as “DISCORD_PUBLIC_KEY”. We will do this a few more times over, so keep this page open.

Back in Discord, on the OAuth2 page, copy the Client ID. Save this as “DISCORD_CLIENT_ID” in the Cloud params page. Next, on the Bot page in Discord, copy the Bot token. Save this as "DISCORD_BOT_TOKEN". 

With those copied, it’s time to install the new app to a Discord server. Go to the OAuth2 section, and then “URL Generator”. Check “Bot” and “applications.commands” in Scopes, and then the same Bot permissions selected earlier when the Bot was created. Copy and paste the Generated URL at the bottom of the form. This should take you to an installation page from Discord, select the server you want to use the app in. 

Now that we have a Discord app ready to roll, let’s write some code. 

Handling Discord Commands

Navigate to your “index.ts” or “index.js” file in the Cloud application. Erase all the template code for a clean slate, and then go to your Terminal with the Cloud shell running. We are going to install a few needed packages to get Discord working in your Cloud app. 

In the Cloud terminal, type: “install discord-interactions”, “@discordjs/rest”, and “discord-api-types/v9”.

We are going to use a few things from each of these packages, as well as some of Cloud’s SDK’s. The first task will be writing a POST endpoint to use as the main channel that user interactions will come through. Copy the following snippet, and save in your editor. This will automatically be synced to your developer sandbox instance. 

import { api, params } from '@serverless/cloud'
import { InteractionResponseType, InteractionType, verifyKeyMiddleware } from 'discord-interactions'
import { REST } from '@discordjs/rest'
import { Routes } from 'discord-api-types/v9'

const commands = [
  {
    name: 'ping',
    description: 'Ping for testing'
  }
]
const rest = new REST({ version: '9' }).setToken(params.DISCORD_BOT_TOKEN)

api.get('/set-commands', async (req, res) => {
  try {
    await rest.put(Routes.applicationGuildCommands(params.DISCORD_CLIENT_ID, 'your-guild-id'), {
      body: commands
    })
    return res.sendStatus(200)
  } catch (e) {
    console.error(e)
    return res.sendStatus(500)
  }
})

const sendResponse = (res: any) => (content: string) => {
  return res.send({
    type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
    data: {
      content
    }
  })
}

api.post('/discord', api.rawBody, verifyKeyMiddleware(params.DISCORD_PUBLIC_KEY), async (req, res) => {
  const message = req.body
  const commandName = message.data.name ?? undefined
  if (!commandName) {
    return res.sendStatus(500)
  }
  const channelId = message.channel_id
  const guildId = message.guild_id
  const username = message.member.user.username

  const reply = sendResponse(res)

  if (message.type === InteractionType.APPLICATION_COMMAND) {
    switch (commandName) {
      case 'ping': {
        return reply('Pong')
      }
    }
  }
  return res.sendStatus(200)
})

To walk through this a bit, we are defining two endpoints, the first (‘GET /set-commands’) is going to be used to refresh the available slash commands we can use within Discord, against the app. We will be adding more to this later, but to start, we will just be doing a “Ping” test to assure everything is connected. One thing to be aware of is that this example is setting commands unique to whatever Guild (Server) your app was installed to, not globally. You can get the Guild ID for any server by enabling developer mode in the Discord app, and right clicking the server name. We will change this to set the app’s global commands later, but they are slower to update, making developing with them difficult. 

The next POST route is where most of the business logic will reside. Notice, there are a few middleware functions being applied to this endpoint before the request is handled. Discord is nice enough to include a verification middleware in their library, but to use it, we need an unaltered request body. For this you can use the Cloud API’s “rawBody” middleware, which reverts any potential mutations. Using your public key from earlier, this will automatically validate incoming requests to your API, rejecting anything else. 

With this in place, copy your developer sandbox URL in your terminal and head back to the Discord Developer panel. In General Information, under "INTERACTIONS ENDPOINT URL", paste your URL with the /discord route appended. This will immediately check your route, and if everything checks out, you should see a “All your edits have been carefully recorded” message at the top. Note, once you deploy a stage via the Cloud CLI, this URL will have to be updated. 

Let’s test out the app now. Navigate to your Discord app with your server of choice open, and try typing “/ping”. An option should appear from your app’s name, hit enter. If you don’t see it, make sure to run your “set-commands” code by hitting it in your browser. You should quickly see a pong returned. Unlike Slack, these messages are public, so I recommend making a temporary private text channel to keep debugging a secret.

Setting up a Spotify App

If you followed the Spotify Slack Playlister blog post, you should already have a Spotify app ready to go. If you haven’t, go check out the post to set up your Spotify app. For existing and new applications, you will need to add the redirect URL to the app’s configuration. While there, let’s get the Spotify Client ID and Secret to add to Cloud params, I saved them as “SPOTIFY_DISCORD_CLIENT_ID” and “SPOTIFY_DISCORD_CLIENT_SECRET”. 

Spotify Callback URL Configuration

With these in place, let’s install a node wrapper for the Spotify API. Type this into the Cloud CLI terminal:

install spotify-web-api-node

To keep things organized, I made a `spotify.ts` file in my app’s root directory.

import { params, data } from '@serverless/cloud'
import SpotifyWebApi from 'spotify-web-api-node'

export const parseSpotifyUrl = (url: string): string | undefined => {
  if (url.includes('open.spotify.com/track') || url.includes('open.spotify.com/playlist')) {
    const id = url.split('/')[4]
    if (id.includes('?')) {
      return id.split('?')[0]
    }
    return id
  }
  return undefined
}

export const createSpotifyAuthUrl = async (guildId: string) => {
  const scopes = ['playlist-modify-private', 'playlist-modify-public', 'playlist-read-private']
  const state = guildId
  const spotify = new SpotifyWebApi({
    clientId: params.SPOTIFY_DISCORD_CLIENT_ID,
    clientSecret: params.SPOTIFY_DISCORD_CLIENT_SECRET,
    redirectUri: params.CLOUD_URL + `/redirect`
  })
  const existingAuth = await data.get(`guild:${guildId}:spotify`)
  if (!existingAuth) {
    return spotify.createAuthorizeURL(scopes, state)
  }
  return undefined
}

export const authSpotifyForGuild = async (authCode: string, guildId: string) => {
  const spotify = new SpotifyWebApi({
    clientId: params.SPOTIFY_DISCORD_CLIENT_ID,
    clientSecret: params.SPOTIFY_DISCORD_CLIENT_SECRET,
    redirectUri: params.CLOUD_URL + `/redirect`
  })
  const { body } = await spotify.authorizationCodeGrant(authCode)
  await data.set(`guild:${guildId}:spotify`, { refreshToken: body.refresh_token })
}

export const refreshGuildSpotifySession = async (guildId: string): Promise<SpotifyWebApi> => {
  const spotify = new SpotifyWebApi({
    clientId: params.SPOTIFY_DISCORD_CLIENT_ID,
    clientSecret: params.SPOTIFY_DISCORD_CLIENT_SECRET,
    redirectUri: params.CLOUD_URL + `/redirect`
  })
  const refreshTokenData = await data.get<any>(`guild:${guildId}:spotify`)
  if (!refreshTokenData) {
    throw new Error('Missing Refresh Token')
  }
  spotify.setRefreshToken(refreshTokenData.refreshToken)
  const refreshData = await spotify.refreshAccessToken()
  spotify.setAccessToken(refreshData.body.access_token)
  return spotify
}

And then imported it into "index.ts", with a new command for starting the Spotify authentication flow from Discord, as well as adding the Cloud SDK “data” to save the playlist ID. 

import { api, params, data } from '@serverless/cloud'
import { authSpotifyForGuild, parseSpotifyUrl, createSpotifyAuthUrl, refreshGuildSpotifySession } from './spotify'


const commands = [
  {
    name: 'ping',
    description: 'Ping for testing'
  },
  {
    name: 'auth-spotify',
    description: 'Authenticate with Spotify'
  }
]

// in api.post(‘/discord’)
case 'auth-spotify': {
  const spotifyAuthUrl = await createSpotifyAuthUrl(guildId)
  if (!spotifyAuthUrl) {
    return reply('Already authenticated spotify')
  }
  return reply(`Please visit ${spotifyAuthUrl} to authenticate with Spotify`)
}

This will allow multiple servers to use one instance of your app, if needed, storing different authentication data by guild (server) id. By default, this command is allowed for anyone in the server to use, but can be edited using the Application Commands Permission object to just allow admins, or even specific users.

Be sure to refresh your app commands by running the "/set-commands" GET endpoint once more. With this set, you can call the "/auth-spotify" command from within Discord to get a Spotify OAuth Link. 

Before taking song additions, we need a playlist to add everything to. Let’s make a "/set" command that will allow server admins to plugin in a custom playlist. 

// TypeScript only
type GuildPlaylist = {
  playlistId: string
  added: string
  channelId: string
  guildId: string
  member: string
}

const commands = [
  {
    name: 'ping',
    description: 'Ping for testing'
  },
  {
    name: 'auth-spotify',
    description: 'Authenticate with Spotify'
  },
  {
    name: 'set',
    description: 'Set the volume of the player',
    options: [
      {
        name: 'url',
        description: 'The Spotify Playlist URL to use',
        type: 3,
        required: true
      }
    ]
  },
  {
    name: 'view',
    description: 'View the playlist URL'
  }
]

// in api.post(‘/discord`) 
case 'view': {
  const playlist = await data.get<any>(`guild:${guildId}:playlist`)
  return reply(`https://open.spotify.com/playlist/${playlist.playlistId}`)
}
case 'set': {
  if (message.data.options) {
    const url = message.data.options[0].value
    const playlistId = parseSpotifyUrl(url)
    await data.set<GuildPlaylist>(
      `guild:${guildId}:playlist`,
      {
        guildId,
        playlistId,
        channelId,
        added: new Date().toISOString(),
        member: message.member.user.username
      },
      {
        overwrite: true
      }
    )
    return reply(`Set Guild Playlist to ${playlistId}`)
  }
}

Rerun the "set-commands" GET route, and try setting the Discord playlist to one that belongs to the authenticated account. Once added, users can access the server playlist by using “/view”.

Adding Songs to the playlist

The final step, and the most important, is allowing users to send in Spotify links to add to the server playlist. For this, we will create another command called “/add”. 

const commands = [
  ...previous commands,
  {
    name: 'add',
    description: 'Add a song to the playlist',
    options: [
      {
        name: 'url',
        description: 'The URL of the track to add',
        type: 3,
        required: true
      }
    ]
  }
]


// in api.post(‘/discord’)
case 'add': {
  if (message.data.options) {
    const url = message.data.options[0].value
    const trackId = parseSpotifyUrl(url)
    if (!trackId) {
      return reply('Invalid Spotify URL')
    } else {
      await data.set<Track>(
        `guild:${guildId}:track:${trackId}`,
        {
          trackId,
          member: username,
          added: new Date().toISOString(),
          guildId,
          channelId
        },
        {
          overwrite: true
        }
      )
      return reply(`Adding ${trackId} to playlist`)
    }
  }
}

data.on(['created'], async (event) => {
  const { item } = event
  if (item.key.includes('track')) {
    const { value } = item
    const { guildId, trackId, channelId, member } = value as Track
    const spotify = await refreshGuildSpotifySession(guildId)
    const guildPlaylist = await data.get<GuildPlaylist>(`guild:${guildId}:playlist`)
    if (!guildPlaylist) {
      return await rest.post(Routes.channelMessages(channelId), { body: { content: 'No playlist set' } })
    }
    const { playlistId } = guildPlaylist as GuildPlaylist
    const currentTracksOnPlaylist = await spotify.getPlaylistTracks(playlistId)
    const playlistIds = new Set(currentTracksOnPlaylist.body.items.map((item) => item.track.id))
    const trackInfo = await spotify.getTrack(trackId)
    const { body } = trackInfo
    const { name, artists } = body
    if (playlistIds.has(trackId)) {
      return await rest.post(Routes.channelMessages(channelId), {
        body: { content: `${name} by ${artists[0].name} is already on server playlist` }
      })
    }
    await data.set<Track>(
      item.key,
      {
        ...value,
        artistName: artists[0].name,
        trackName: name
      },
      { overwrite: true }
    )
    await spotify.addTracksToPlaylist(playlistId, [`spotify:track:${trackId}`])
    return await rest.post(Routes.channelMessages(channelId), {
      body: { content: `${member} added ${name} by ${artists[0].name} to server playlist` }
    })
  }
})

There is a lot here, so let’s start by walking through the “/add” command. When an “add” command comes in, first the options are checked. This is required to use the command, so it will be present as the first item in the array. This is the user’s argument, but we don’t know yet if it is a valid Spotify track URL, as it is only defined as a string (this is what “type: 3” refers to in commands). If it is a valid track url, is it parsed via the Spotify utility function, and the ID is returned. This, along with the username, channelId, and guildId, is saved to the track using “data”. Saving all of this will allow the app to send a message back to the respective guild, and server, for confirmation that it was successfully added. 

To keep response times low, the track is added using the “data.on” handler from Serverless Data, firing anytime a new item is created. When a new one comes in, we want to be sure it is a track, before beginning the business logic of adding it to the Spotify playlist. 

Once inside the track block, we need all the related metadata about the save, especially to pull the correct guild’s Spotify authentication data. With the refreshToken from Spotify loaded, we refresh the session and return a newly authenticated Spotify instance for further calls. 

The first check performed is listing the current tracks on the playlist, and making sure that this song has not been added by any other means. We do this by converting the ID’s into a set, and asserting that the incoming track ID is not included. For better messaging to the user, we also load in the track’s metadata, like the artist name and track name, as it’s better understood than a lone Spotify URI. 

Once verified, an update is performed to the just-saved track item, to include the track metadata for future viewing, and it is added to the Guild playlist using “addTracksToPlaylist”. 

Assuming all is successful, a confirmation message is sent back to the channel the command was invoked from originally, informing any members that a new song has been added. 

Conclusion

If you made it this far, congrats! You have a fully functional Spotify Playlister for your Discord. You can also find the complete code for this demo application here. While making this, I was pleasantly surprised by the amount of depth to Discord’s APIs, and hope this proves to be a good starting point for anyone diving into any Discord app development. 

Check out more of what Serverless Cloud can do here, and to get full scope of what else you can do with Discord, check out their documentation here.

Subscribe to our newsletter to get the latest product updates, tips, and best practices!

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.