Create A Discord Bot that Assigns a Role to NFT Holders!

Create A Discord Bot that Assigns a Role to NFT Holders!

Create A Discord Bot That Gives NFT Holders A Role

In this guide, we'll set up a Discord bot that checks if a wallet has an NFT from a collection, and grants them a special role on our Discord server if they do!

Similar to Collab.Land, we'll ask the user to sign in with their wallet as well as their Discord account on our web application, and ask a bot we create to grant them a role on our server using the Discord API running on a Next.js API route.

You can grab the source code for this project using the link below:

Let's do it!

Creating a thirdweb app

To get started, we can use the thirdweb CLI.

npx thirdweb create

We'll be using TypeScript and Next.js for this guide; so give your app a name and select Next.js for the framework, and TypeScript for the language.

For this guide, we'll assume you already have a Discord server created and a role set up in the server. If you don't have one, go ahead and create one now and come back to this guide, because next up, we'll create a bot and invite it to our server!

Creating A Discord Bot

To create a Discord bot, head to the Discord Developer Portal and click on New Application, give it a name and click create!

image.png

Once it's created, head to the Bot tab, and click Add Bot.

image.png

Give your bot a username, and I'm unchecking the Public Bot field so that only we can invite our bot.

image.png

Scroll down to Bot Permissions and give our bot the Manage Roles permission:

It's important to note that you should only give your bot the roles it requires. If your bot token is compromised, other users can perform any actions you have permitted it to do.

image.png

Once you're ready, click Save Changes!

Now we're ready to invite our bot to our server!

Click OAuth2 > URL Generator on the sidebar:

Select bot and Manage Roles scopes.

image.png

Copy the Generated URL and open it in your browser.

image.png

Make sure it is the bot you expect, select the server you want to add your bot to and click Continue. It will ask you to approve this bot's permissions, you should see a prompt to authorize the bot for Manage Roles permissions:

image.png

Click Authorise, once successful, you'll see an Authorised window

image.png

And your bot will be added to your server - say hi!

image.png

Authenticating Users

To authenticate users with Discord, we'll be using the library `NextAuth

Create a new folder inside of pages called api, and within that, create another folder called auth, and within this auth folder, create a file called [...nextauth].ts!

yarn add next-auth

This is where we'll configure our NextAuth setup and allow people to sign in to our application using Discord.

Back in your Discord Developer Portal, copy across your Client ID and Client Secret into environment variables in your project, by creating a .env.local file at the root of the directory.

image.png

CLIENT_ID=xxxxx
CLIENT_SECRET=xxxxx

We also need to add a Redirect URL into our Application while we're here:

image.png

Now let's make our [...nextauth] API route look like this:

import NextAuth from "next-auth";
import DiscordProvider from "next-auth/providers/discord";
export default NextAuth({
  // Configure one or more authentication providers
  providers: [
    DiscordProvider({
      clientId: process.env.CLIENT_ID as string,
      clientSecret: process.env.CLIENT_SECRET as string,
    }),
  ],

  // When the user signs in, get their token
  callbacks: {
    async jwt({ token, account }) {
      // Persist the OAuth access_token to the token right after signin
      if (account) {
        token.userId = account.providerAccountId;
      }
      return token;
    },

    async session({ session, token, user }) {
      // Send properties to the client, like an access_token from a provider.
      session.userId = token.userId;
      return session;
    },
  },
});

You might notice we have some modifications to the data that gets returned inside these callbacks. We're doing this to add the user's ID into the token that gets returned by NextAuth, because we want to access that value when we try and grant this user the role (we'll need their ID).

To access NextAuth's hooks such as signIn, we need to wrap our application in the SessionProvider:

import type { AppProps } from "next/app";
import { ChainId, ThirdwebProvider } from "@thirdweb-dev/react";
import { SessionProvider } from "next-auth/react";
import "../styles/globals.css";

// This is the chainId your dApp will work on.
const activeChainId = ChainId.Mumbai;

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <ThirdwebProvider desiredChainId={activeChainId}>
      <SessionProvider session={pageProps.session}>
        <Component {...pageProps} />
      </SessionProvider>
    </ThirdwebProvider>
  );
}

export default MyApp;

Now we're ready for users to authenticate to our application using Discord!

Let's create a folder called components and create a SignIn.tsx component within that folder. This component will ask the user to both:

  1. Sign In With Their Wallet
  2. Authenticate With Discord

In this component, three states can occur:

  1. The user is connected to both wallet and Discord => We show them the main page.
  2. The user is not connected to wallet => We ask them to connect their wallet.
  3. The user is not connected to Discord=> We ask them to authenticate with Discord.

Once the user is in state 1, (has both wallet connected and Discord connected), we can show them a button that will run some code on our server to check if they own an NFT. If they do own the NFT, our Discord bot will assign them a role in our Discord server!

Let's write the code for these three states:

Imports and hook definitions

import { useAddress, useDisconnect, useMetamask } from "@thirdweb-dev/react";
import { useSession, signIn, signOut } from "next-auth/react";
import React from "react";
import styles from "../styles/Home.module.css";

export default function SignIn() {
  const address = useAddress();
  const connectWithMetamask = useMetamask();
  const disconnectWallet = useDisconnect();
  const { data: session } = useSession();

  // rest of the code here
}

State 1 - Both Wallet + Discord Connected

// 1. The user is signed into discord and connected to wallet.
if (session && address) {
  return (
    <div className={styles.bigSpacerTop}>
      <a onClick={() => signOut()} className={styles.secondaryButton}>
        Sign out of Discord
      </a>
      |<a onClick={() => disconnectWallet()} className={styles.secondaryButton}>
        Disconnect wallet
      </a>
    </div>
  );
}

State 2 - Connect Wallet

// 2. Connect Wallet
if (!address) {
  return (
    <div className={styles.main}>
      <h2 className={styles.noGapBottom}>Connect Your Wallet</h2>
      <p>Connect your wallet to check eligibility.</p>
      <button
        onClick={connectWithMetamask}
        className={`${styles.mainButton} ${styles.spacerTop}`}
      >
        Connect Wallet
      </button>
    </div>
  );
}

State 3 - Connect Discord

// 3. Connect with Discord (OAuth)
if (!session) {
  return (
    <div className={`${styles.main}`}>
      <h2 className={styles.noGapBottom}>Sign In with Discord</h2>
      <p>Sign In with Discord to check your eligibility for the NFT!</p>

      <button
        onClick={() => signIn("discord")}
        className={`${styles.mainButton} ${styles.spacerTop}`}
      >
        Connect Discord
      </button>
    </div>
  );
}

// default return nothing
return null;

Back on our home page, let's change the logic to show the user the SignIn component when we can't find both an address and session:

// index.tsx
import { useAddress, useSDK } from "@thirdweb-dev/react";
import { useSession } from "next-auth/react";
import SignIn from "../components/SignIn";
import type { NextPage } from "next";
import styles from "../styles/Home.module.css";

const Home: NextPage = () => {
  const address = useAddress();
  const { data: session } = useSession();
  const sdk = useSDK();

  return (
    <div>
      <div className={styles.container} style={{ marginTop: 0 }}>
        <SignIn />

        {address && session && (
          <div className={styles.collectionContainer}>
            <button className={styles.mainButton}>Give me the role!</button>
          </div>
        )}
      </div>
    </div>
  );
};

export default Home;

We now have a page that prompts the user to connect their wallet, and then Sign In With Discord:

image.png

When you click the Connect Discord button, NextAuth handles the OAuth flow for us:

image.png

Once the user has connected both their wallet and Discord, we show them a button that says Give me the role!, we'll add some functionality to this button next!

Granting Discord Roles

To grant a role to the connected user, we are going to use the Discord API on behalf of the bot that we created. Specifically, we'll be hitting the Add Guild Member Role API endpoint:

image.png

To make requests from our bot, we'll need a token to act on its behalf. To generate a token, head to the Bot tab from your Discord Developer portal, and click Reset Token on your bot:

image.png

We then need to store this inside our environment variables as well securely:

BOT_TOKEN=xxxx

Next, let's make another API route in our api folder called grant-role.ts.

Within this API route, we're going to grant a user the discord role if they own an NFT from our collection. This involves a few steps:

  1. Authenticate the login payload of the user (ensure the user owns the wallet)
  2. Check that wallet's NFT balance
  3. Make a request to the Discord API to grant a role

Firstly, let's set up the barebones of our API route:

import { ThirdwebSDK } from "@thirdweb-dev/sdk";
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "next-auth/react";

export default async function grantRole(
  req: NextApiRequest,
  res: NextApiResponse,
) {
  // Get the login payload out of the request
  const { loginPayload } = JSON.parse(req.body);

  // Get the NextAuth session so we can use the user ID as part of the discord API request
  const session = await getSession({ req });

  if (!session) {
    res.status(401).json({ error: "Not logged in" });
    return;
  }
}

Authenticate the login payload

On the client, we're going to ask the user to sign in using the Authentication SDK. This requires the user to sign a message which generates a login payload; we'll be sending this in the body of the request.

// Authenticate login payload
const sdk = new ThirdwebSDK("mumbai");
const domain = "thirdweb.com"; // This should be the domain name of your own website
// Verify the login payload is real and valid
const verifiedWalletAddress = sdk.auth.verify(domain, loginPayload);

// If the login payload is not valid, return an error
if (!verifiedWalletAddress) {
  res.status(401).json({ error: "Invalid login payload" });
  return;
}

This step ensures that the user owns the wallet that we are going to check. It prevents users from sending a wallet address that is not theirs to this API endpoint, and falsely granting them the role.

Check the wallet's NFT balance

We use the SDK to view the balance of the wallet address for token ID 0 of our ERC-1155 NFT collection.

// Check if this user owns an NFT
const editionDrop = sdk.getEditionDrop(
  "0x1fCbA150F05Bbe1C9D21d3ab08E35D682a4c41bF",
);

// Get addresses' balance of token ID 0
const balance = await editionDrop.balanceOf(verifiedWalletAddress, 0);

Granting Users the role

Here, we make the request to the discord API to grant the user a role by using our bot token as the authorization header.

In order to do this, you'll need to create a role in your server, and copy both your server and role ID into the variables. You can learn how to do that from this guide.

if (balance.toNumber() > 0) {
  // If the user is verified and has an NFT, return the content

  // Make a request to the Discord API to get the servers this user is a part of
  const discordServerId = "999533680663998485";
  const { userId } = session;
  const roleId = "999851736028172298";
  const response = await fetch(
    // Discord Developer Docs for this API Request: https://discord.com/developers/docs/resources/guild#add-guild-member-role
    `https://discordapp.com/api/guilds/${discordServerId}/members/${userId}/roles/${roleId}`,
    {
      headers: {
        // Use the bot token to grant the role
        Authorization: `Bot ${process.env.BOT_TOKEN}`,
      },
      method: "PUT",
    },
  );

  // If the role was granted, return the content
  if (response.ok) {
    res.status(200).json({ message: "Role granted" });
  }

  // Something went wrong granting the role, but they do have an NFT
  else {
    res
      .status(500)
      .json({ error: "Error granting role, are you in the server?" });
  }
}
// If the user is verified but doesn't have an NFT, return an error
else {
  res.status(401).json({ error: "User does not have an NFT" });
}

That's it for our API route, now we need to call this from our client!

Back on the index.tsx page, let's create a function called requestGrantRole inside the component and make a fetch request to this API endpoint.

const sdk = useSDK();

async function requestGrantRole() {
  // First, login and sign a message
  const domain = "thirdweb.com"; // This should be the domain name of your own website
  const loginPayload = await sdk?.auth.login(domain);

  // Then make a request to our API endpoint.
  try {
    const response = await fetch("/api/grant-role", {
      method: "POST",
      body: JSON.stringify({
        loginPayload,
      }),
    });
    const data = await response.json();
    console.log(data);
  } catch (e) {
    console.error(e);
  }
}

And attach this function to our button:

<button className={styles.mainButton} onClick={requestGrantRole}>
  Give me the role!
</button>

That's it! We're ready to test it out!

Demo

Connect our wallet, authenticate with Discord, and sign in with Ethereum:

image.png

The grant-role API endpoint runs, granting the connected Discord user the role if they have an NFT from the collection:

image.png

We now have the role in our server!

image.png

Going to production

In a production environment, you need to have an environment variable called NEXTAUTH_SECRET for the Discord Oauth to work.

You can learn more about it here: next-auth.js.org/configuration/options

You can quickly create a good value on the command line via this openssl command.

openssl rand -base64 32

And add it as an environment variable in your .env.local file:

NEXTAUTH_SECRET=<your-value-here>

Conclusion

We've made our very own Discord role granting application using thirdweb, the Discord API, and NextAuth!

You can check out the full code for this project on our GitHub:

github.com/thirdweb-example/discord-role-gr..

For any questions, suggestions, join our discord at https://discord.gg/cd thirdweb.