Skip to content

Commit dca3516

Browse files
authored
Merge pull request #2 from jzxhuang/pokemon-example-vercel-og
adds pokemon example using @vercel/og for image generation
2 parents da056c0 + 9e3572d commit dca3516

File tree

13 files changed

+302
-125
lines changed

13 files changed

+302
-125
lines changed

.env.local.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,7 @@ DISCORD_APP_PUBLIC_KEY=
77
# Settings -> Bot
88
# Required to register commands, not required to actually run the bot
99
DISCORD_BOT_TOKEN=
10+
11+
# Set this with your local ngrok URL for the pokemon images to be served correctly.
12+
# Ex: https://1b539072925d.ngrok.app
13+
ROOT_URL=

README.md

Lines changed: 44 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,27 @@
1-
# NextBot: Next.js Discord Bot Template that runs in the Edge Runtime
1+
# NextBot: Next.js Discord Bot Template that runs 100% at the Edge Runtime
22

3-
NextBot is a template for building and deploying a Discord bot with Next.js. It runs 100% in the edge runtime so you get
4-
lightning-fast responses and zero cold starts. It uses Discord interactions webhooks to receive and reply to commands.
3+
NextBot is a template for building and deploying a Discord bot with Next.js. It's configured to run fully at the edge,
4+
delivering lightning-fast responses with no cold starts.
55

66
![Demo GIF](docs/demo.gif)
77

88
- Runs at the edge: Lightning-fast responses, no cold start.
99
- Free & easy to deploy: Deploy to Vercel in seconds at no cost. No need to host a server or VM to run your bot! Don't
1010
bother with Heroku, EC2, etc.
11-
- Easy to extend: Since the bot is built on Next.js, you can easily build an accompanying webapp in the same repo.
11+
- Extensible and scalable: Leverage Next.js to pair a dashboard for your bot, or use features like `next/og` to generate
12+
dynamic images!
1213

1314
## Try it out
1415

15-
Join https://discord.gg/NmXuqGgkb3 to try out a demo of NextBot. Type one of these slash commands into the general
16-
channel:
16+
Join https://discord.gg/NmXuqGgkb3 to try out NextBot. Here are some commands I've added to the demo bot as an example
17+
of what you can build!
1718

18-
- `/ping`
19-
- `/invite`
20-
- `/randompic`
19+
| Command | Description |
20+
| ------------ | ------------------------------------------------------------------------------------------------------------------------------ |
21+
| `/pokemon` | Returns an image that contains a Pokemon's sprite, name, and pokedex number. The image is generated dynamically using next/og. |
22+
| `/ping` | Ping pong! The bot will respond with "Pong". |
23+
| `/invite` | Returns a link to invite the bot to your own server. |
24+
| `/randompic` | Returns a random picture. |
2125

2226
Or add NextBot to your own server with this link:
2327
https://discord.com/api/oauth2/authorize?client_id=837427503059435530&permissions=2147485696&scope=bot%20applications.commands
@@ -26,14 +30,16 @@ You can also send slash commands through DM to the bot as long as you're in a mu
2630

2731
## Development
2832

29-
Node.js 18+ is required.
33+
See https://nextjs.org/docs/getting-started/installation for minimum requirements.
3034

3135
### Setup
3236

37+
These steps only need to be done once.
38+
3339
- Clone the repo. This is a
3440
[template repository](https://docs.github.com/en/repositories/creating-and-managing-repositories/creating-a-repository-from-a-template)
3541
so you can click the green "Use this template" button on GitHub to create your own repo!
36-
- Run `yarn` to install dependencies.
42+
- Run `yarn` to install dependencies.
3743
- [Create a new Discord application](https://discord.com/developers/applications).
3844
- In the `Bot` settings of your Discord application, enable the `Message Content` intent.
3945
- Fill out environment variables:
@@ -57,6 +63,7 @@ For this guide, I'll be using ngrok.
5763
- In the Discord app settings, set `Interactions Endpoint URL` to `<YOUR_PUBLIC_TUNNELED_NGROK_URL>/api/interactions`.
5864
Make sure to use the `https` URL!
5965
- Save changes in the Discord app settings.
66+
- Set the value of `ROOT_URL` in `.env.local` to your ngrok URL.
6067

6168
In order to verify your interactions endpoint URL, Discord will send a `PING` message to your bot, and the bot should
6269
reply with a PONG (see `src/pages/api/interactions.ts`). If this is successful, your bot is ready to go!
@@ -104,26 +111,33 @@ commands!
104111

105112
- `src/app/api/interactions/route.ts`: This is the main route handler for the Interactions Endpoint. It receives
106113
interactions from Discord and handles them accordingly.
107-
- `src/discord/verify-incoming-request.ts`: Helper functions to verify incoming requests from Discord, as outlined in https://discord.com/developers/docs/interactions/receiving-and-responding#security-and-authorization.
114+
- `src/discord/verify-incoming-request.ts`: Helper functions to verify incoming requests from Discord, as outlined in
115+
https://discord.com/developers/docs/interactions/receiving-and-responding#security-and-authorization.
108116
- `src/app/page.tsx`: A basic web page. This could be your admin portal or whatever you'd like!
109117

110118
## Why Next.js (instead of Express, serverless, or Cloudflare Workers)?
111119

112-
NextBot leverages Next.js 13
113-
[Route Handlers](https://nextjs.org/docs/app/building-your-application/routing/route-handlers) to receive and respond to
114-
interactions. But why Next.js instead of something like Express or serverless (AWS Lambda)?
115-
116-
- Next.js is free and effortless to deploy on Vercel. Don't bother with trying to host your Express server
117-
- Compared to serverless, edge is faster (no cold start) and cheaper (Edge Runtimes have more generous free tiers and
118-
are cheaper per request)
119-
120-
A better comparison would be hosting a Discord bot on Cloudflare Workers, as shown in this
121-
[official Discord tutorial](https://discord.com/developers/docs/tutorials/hosting-on-cloudflare-workers). The Next.js
122-
edge runtime is built on Cloudflare Workers! Still, I think Next.js has some notable benefits:
123-
124-
- Next.js has the advantage of being a full web-app framework, making it easy to build an accompanying web app to go
125-
along with your Discord app!
126-
- Deploying on Vercel is not only simple, but it also scales out very effectively if you need to build a more complex
127-
app (analytics, logging, integrations, etc.)
128-
- You can use Next.js/Vercel features like [Dynamic Image Generation](NextBot is a template for building and deploying a Discord bot with Next.js. It runs 100% in the edge runtime so you get
129-
lightning-fast responses and zero cold starts).
120+
NextBot leverages Next.js [Route Handlers](https://nextjs.org/docs/app/building-your-application/routing/route-handlers)
121+
to receive and respond to interactions. Why Next.js instead of something like Express or serverless (AWS Lambda)?
122+
123+
- Next.js is free and easy to deploy on Vercel. Don't bother with managing VMs, EC2 instances, etc.
124+
- Compared to serverless (i.e. AWS Lambda), edge is faster (no cold start) and cheaper (edge has a more generous free
125+
tiers and is cheaper per request)
126+
- Next.js is a full web-app framework, making it easy to build an accompanying web app to go along with your Discord
127+
app. An example of this can be seen in `src/app/page.tsx`.
128+
- You can use Next.js features like [@vercel/og](https://vercel.com/docs/functions/edge-functions/og-image-generation).
129+
The `/pokemon` command demonstrates this! It generates a dynamic image at runtime, and responses are cached at the
130+
edge.
131+
- Vercel scales out very effectively for a more complex Discord bot, for example if you need to add analytics, logging,
132+
auth, etc.
133+
134+
Note: Discord's official docs include a
135+
[full tutorial for hosting a Discord bot on Cloudflare Workers](https://discord.com/developers/docs/tutorials/hosting-on-cloudflare-workers).
136+
This is the best comparison to NextBot: Next's edge runtime is built on Cloudflare Workers!
137+
138+
In the `/pokemon` command, I demonstrate some of the powerful Next.js features that can use!
139+
140+
- The image in the response is generated dynamically using
141+
[next/og](https://nextjs.org/docs/app/building-your-application/optimizing/metadata#dynamic-image-generation).
142+
- Requests to [PokeAPI](https://pokeapi.co/) are [cached automatically](https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating) by Next.js
143+
- The dynamic image itself is also cached, so it only needs to be generated once.

assets/pokemon/Pokemon Hollow.ttf

41.8 KB
Binary file not shown.

assets/pokemon/Pokemon-Solid.ttf

24.9 KB
Binary file not shown.

docs/demo.gif

193 KB
Loading

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
"dotenv": "^16.3.1",
1515
"ky": "^0.33.3",
1616
"nanoid": "^4.0.2",
17-
"next": "^13.5.2",
17+
"next": "^14.0.2",
1818
"postcss": "^8.4.30",
1919
"react": "^18.2.0",
2020
"react-dom": "^18.2.0",

src/app/api/interactions/route.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ export const runtime = "edge"
2020

2121
// Your public key can be found on your application in the Developer Portal
2222
const DISCORD_APP_PUBLIC_KEY = process.env.DISCORD_APP_PUBLIC_KEY
23+
const ROOT_URL = process.env.VERCEL_URL
24+
? `https://${process.env.VERCEL_URL}`
25+
: process.env.ROOT_URL || "http://localhost:3000"
26+
27+
function capitalizeFirstLetter(s: string) {
28+
return s.charAt(0).toUpperCase() + s.slice(1)
29+
}
2330

2431
/**
2532
* Handle Discord interactions. Discord will send interactions to this endpoint.
@@ -58,6 +65,80 @@ export async function POST(request: Request) {
5865
},
5966
})
6067

68+
case commands.pokemon.name:
69+
if (!interaction.data.options || interaction.data.options?.length < 1) {
70+
return NextResponse.json({
71+
type: InteractionResponseType.ChannelMessageWithSource,
72+
data: {
73+
content: "Oops! Please enter a Pokemon name or Pokedex number.",
74+
flags: MessageFlags.Ephemeral,
75+
},
76+
})
77+
}
78+
79+
const option = interaction.data.options[0]
80+
// @ts-ignore
81+
const idOrName = String(option.value).toLowerCase()
82+
83+
try {
84+
const pokemon = await fetch(`https://pokeapi.co/api/v2/pokemon/${idOrName}`).then((res) => {
85+
return res.json()
86+
})
87+
const types = pokemon.types.reduce(
88+
(prev: string[], curr: { type: { name: string } }) => [...prev, capitalizeFirstLetter(curr.type.name)],
89+
[]
90+
)
91+
92+
const r = {
93+
type: InteractionResponseType.ChannelMessageWithSource,
94+
data: {
95+
embeds: [
96+
{
97+
title: capitalizeFirstLetter(pokemon.name),
98+
image: {
99+
url: `${ROOT_URL}/api/pokemon/${idOrName}`,
100+
},
101+
fields: [
102+
{
103+
name: "Pokedex",
104+
value: `#${String(pokemon.id).padStart(3, "0")}`,
105+
},
106+
{
107+
name: "Type",
108+
value: types.join("/"),
109+
},
110+
],
111+
},
112+
],
113+
},
114+
}
115+
return NextResponse.json({
116+
type: InteractionResponseType.ChannelMessageWithSource,
117+
data: {
118+
embeds: [
119+
{
120+
title: capitalizeFirstLetter(pokemon.name),
121+
image: {
122+
url: `${ROOT_URL}/api/pokemon/${idOrName}`,
123+
},
124+
fields: [
125+
{
126+
name: "Pokedex",
127+
value: `#${String(pokemon.id).padStart(3, "0")}`,
128+
},
129+
{
130+
name: "Type",
131+
value: types.join("/"),
132+
},
133+
],
134+
},
135+
],
136+
},
137+
})
138+
} catch (error) {
139+
throw new Error("Something went wrong :(")
140+
}
141+
61142
case commands.randompic.name:
62143
const { options } = interaction.data
63144
if (!options) {
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { ImageResponse } from "next/og"
2+
3+
export const runtime = "edge"
4+
5+
/**
6+
* Generates an image containing a
7+
* Pokemon' name, Pokedex number, and image.
8+
*
9+
* Accepts either a Pokemon's name or Pokedex number as the slug.
10+
* If the pokemon cannot be found, returns Unown.
11+
*
12+
* @see https://vercel.com/docs/functions/edge-functions/og-image-generation
13+
*
14+
* Responses are automatically cached in production.
15+
*
16+
* @see https://nextjs.org/docs/app/building-your-application/routing/route-handlers
17+
*/
18+
export async function GET(_: Request, { params }: { params: { slug: string } }) {
19+
// From https://www.dafont.com/pokemon.font
20+
const fontData = await fetch(new URL("../../../../../assets/pokemon/Pokemon-Solid.ttf", import.meta.url)).then(
21+
(res) => res.arrayBuffer()
22+
)
23+
24+
let name = "Unown"
25+
let number = "???"
26+
let exists = false
27+
28+
const { slug } = params
29+
30+
/** @see https://pokeapi.co/docs/v2#pokemon-section */
31+
try {
32+
const pokemon = await fetch(`https://pokeapi.co/api/v2/pokemon/${slug}`).then((res) => res.json())
33+
name = pokemon.name
34+
number = String(pokemon.id)
35+
exists = true
36+
} catch (_) {
37+
// Pokemon doesn't exist, or some other error.
38+
// We'll return Unown :)
39+
}
40+
41+
return new ImageResponse(
42+
(
43+
<div
44+
tw="flex h-full w-full items-center justify-center bg-white text-center text-4xl text-black"
45+
style={{ fontFamily: "Pokemon" }}
46+
>
47+
<div tw="flex flex-col items-center text-center py-8 px-8">
48+
<p tw="m-0 font-bold text-7xl tracking-wide capitalize">{name}</p>
49+
<p tw="m-0 text-5xl pt-6 tracking-wider">#{number.padStart(3, "0")}</p>
50+
</div>
51+
52+
{/* eslint-disable-next-line @next/next/no-img-element */}
53+
<img
54+
alt={name}
55+
tw="ml-5"
56+
width="360"
57+
height="360"
58+
src={`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/${
59+
exists ? number : 201
60+
}.png`}
61+
/>
62+
</div>
63+
),
64+
{
65+
width: 960,
66+
height: 540,
67+
fonts: [{ name: "Pokemon", data: fontData, style: "normal" }],
68+
}
69+
)
70+
}

src/app/global-commands.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1-
import { getGlobalCommands } from "@/discord/client"
1+
import { APIApplication } from "discord-api-types/v10"
22

33
export async function GlobalCommands() {
44
try {
5-
const commands = await getGlobalCommands({ appId: process.env.DISCORD_APP_ID! })
5+
const commands = await fetch(`https://discord.com/api/v8/applications/${process.env.DISCORD_APP_ID}/commands`, {
6+
headers: {
7+
Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN}`,
8+
},
9+
next: { revalidate: 60 * 5 },
10+
}).then((res) => res.json() as Promise<APIApplication[]>)
611
if (commands.length <= 0) {
712
return <p className="pt-6">No commands found :(</p>
813
}

src/app/page.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Suspense } from "react"
12
import { GlobalCommands } from "./global-commands"
23

34
export default async function Page() {
@@ -33,7 +34,9 @@ export default async function Page() {
3334
This is an example of an admin portal might look like. It leverages RSCs to fetch the Slash commands
3435
associated with the Discord bot!
3536
</p>
36-
<GlobalCommands />
37+
<Suspense fallback={null}>
38+
<GlobalCommands />
39+
</Suspense>
3740
</section>
3841
</main>
3942
)

0 commit comments

Comments
 (0)