Create A Discord Bot That Gives NFT Holders A Role

Create A Discord Bot That Gives NFT Holders A Role - thirdweb Guides

⚠️ Warning: This guide currently uses v4 of the Connect SDK. For v5 (latest) code snippets, please check out our documentation while this guide is being updated. ⚠️

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.

Let's do it!

Creating a thirdweb app

To get started, we can use the thirdweb CLI

npx thirdweb@latest create app

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!

Create a new discord application

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

Add a bot to the discord app

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

Provide the bot a username

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.

Generate a URL with Manage Role permissions for bot

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.

Generate a URL with Manage Role permissions for bot

Copy the Generated URL and open it in your browser.

Invite the bot to your server

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:

Provide it the necessary permissions

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

The bot has been added successfully

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.

Copy the Client ID and Client Secret of your bot
CLIENT_ID=xxxxx
CLIENT_SECRET=xxxxx
AUTH_SECRET=some random string

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

Add a redirect url

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
  secret: process.env.AUTH_SECRET,

  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. We will also be adding configuration for thirdweb auth so that we can authenticate user wallets on the backend by making the user sign a message:

import type { AppProps } from "next/app";
import { ChainId, ThirdwebProvider } from "@thirdweb-dev/react";
import { SessionProvider } from "next-auth/react";
import ThirdwebGuideFooter from "../components/ThirdwebGuideFooter";
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}
      authConfig={{
        domain: process.env.NEXT_PUBLIC_THIRDWEB_AUTH_DOMAIN!,
        authUrl: "/api/thirdweb-auth",
      }}
    >
      <SessionProvider session={pageProps.session}>
        <Component {...pageProps} />
        <ThirdwebGuideFooter />
      </SessionProvider>
    </ThirdwebProvider>
  );
}

export default MyApp;

Now create a new folder thirdweb-auth inside of pages/api and create a file called [...thirdweb].ts inside of that folder. Have the following contents in the file:

import { ThirdwebAuth } from "@thirdweb-dev/auth/next";
import { PrivateKeyWallet } from "@thirdweb-dev/auth/evm";

// Here we configure thirdweb auth with a domain and wallet
export const { ThirdwebAuthHandler, getUser } = ThirdwebAuth({
  domain: process.env.NEXT_PUBLIC_THIRDWEB_AUTH_DOMAIN || "",
  wallet: new PrivateKeyWallet(process.env.THIRDWEB_AUTH_PRIVATE_KEY || ""),
});

// Use the ThirdwebAuthHandler as the default export to handle all requests to /api/auth/*
export default ThirdwebAuthHandler();

The above code will handle wallet authentication for us. Make sure you add two environment variables to your .env.local file:

NEXT_PUBLIC_THIRDWEB_AUTH_DOMAIN=xxxxx
THIRDWEB_AUTH_PRIVATE_KEY=xxxxx

Ensure you replace the values with your domain and a wallet private key you want to use for authentication. It doesn't need to be a live wallet and you can create a new wallet for this purpose.

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 {
  ConnectWallet,
  useAddress,
  useLogin,
  useUser,
} 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 { data: session } = useSession();
  const { isLoggedIn } = useUser();
  const login = useLogin();

  // 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 - Sign Message

// 3. sign message
if (!isLoggedIn) {
  return (
    <div className={`${styles.main}`}>
      <h2 className={styles.noGapBottom}>Sign using your wallet</h2>
      <p>
        This proves that you really own the wallet that you've claimed to be
        connected.
      </p>

      <button
        onClick={async () => {
          await login.login();
        }}
        className={`${styles.mainButton} ${styles.spacerTop}`}
      >
        Sign message!
      </button>
    </div>
  );
}

State 4 - 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 } 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();

  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:

Sign in with Discord

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

Authenticate using discord

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:

Copy your bot token

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 { getServerSession } from "next-auth/next";
import { authOptions } from "./auth/[...nextauth]";
import { getUser } from "./thirdweb-auth/[...thirdweb]";

export default async function grantRole(
  req: NextApiRequest,
  res: NextApiResponse
) {
  // Get data from thirdweb auth, fail request if not signed in
  const user = await getUser(req);

  if (!user) {
    return res.status(401).json({ error: "Wallet not authorized!" });
  }

  // Get the Next Auth session so we can use the user ID as part of the discord API request
  const session = await getServerSession(req, res, authOptions);

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

The above code checks whether the user has an authenticated wallet and is connected to Discord. If not, we return an error.

Check the wallet's NFT balance

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

// Initialize the SDK
const sdk = new ThirdwebSDK("mumbai");

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

// Get addresses' balance of token ID 0
const balance = await editionDrop.balanceOf(user?.address!, 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.

async function requestGrantRole() {
  // Then make a request to our API endpoint.
  try {
    const response = await fetch("/api/grant-role", {
      method: "POST",
    });
    const data = await response.json();
    console.log(data);
    alert("Check the console for the response!");
  } 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:

Signature Request

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:
https://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 repository associated with this guide.

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