Skip to content

Commit

Permalink
Copy API code and add Pokedex integration (#9)
Browse files Browse the repository at this point in the history
* copy api code

* prettier
  • Loading branch information
thatguyinabeanie authored Nov 10, 2024
1 parent e8bafbd commit 77c8983
Show file tree
Hide file tree
Showing 9 changed files with 726 additions and 18 deletions.
8 changes: 8 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,20 +32,28 @@
"Bloodmoon",
"compat",
"Embla",
"galar",
"gmax",
"hoenn",
"ianvs",
"jiti",
"johto",
"jullerino",
"JWKS",
"kalos",
"nitropack",
"paldea",
"Pressable",
"prettiercache",
"sinnoh",
"sluggable",
"subrouters",
"thatguyinabenaie",
"tsbuildinfo",
"tseslint",
"typesafe",
"unauthed",
"unova",
"Ursaluna"
],
"coverage-gutters.showGutterCoverage": false,
Expand Down
1 change: 1 addition & 0 deletions apps/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"next": "catalog:",
"openapi-fetch": "^0.13.0",
"openapi-typescript-helpers": "^0.0.15",
"pokedex-promise-v2": "^4.2.0",
"react": "catalog:react18",
"react-dom": "catalog:react18",
"server-only": "^0.0.1",
Expand Down
63 changes: 63 additions & 0 deletions apps/nextjs/src/app/api/cookies/user-id/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";

import {
generateSignature,
getCookie,
setResponseCookies,
} from "~/lib/cookies/cookies";

export const runtime = "edge";

export async function POST(_req: NextRequest) {
const { userId } = await auth();
if (!userId) {
return NextResponse.json(
{ error: "Logged in account is required" },
{ status: 401 },
);
}

const [response, setCookies] = setResponseCookies();
const userIdCookie = await getCookie("userId");

if (userIdCookie) {
const [storedUserId, signature] = userIdCookie.split(".");
const expectedSignature = await generateSignature(storedUserId ?? "");

if (!storedUserId || !signature || signature !== expectedSignature) {
const msg = `Signature verification failed for userId cookie. Stored userId: ${storedUserId}, Expected signature: ${expectedSignature}`;

console.warn(msg); // esl return await setUserIdCookie(setCookies, userId, response);
}

const cookieExpiryDateValue = await getCookie("userId.expires");

if (!cookieExpiryDateValue) {
console.warn("Missing 'userId.expires' cookie."); // esl return await setUserIdCookie(setCookies, userId, response);
}

const cookieExpiryDate = new Date(cookieExpiryDateValue);

if (Number.isNaN(cookieExpiryDate.getTime())) {
console.warn("Invalid date in 'userId.expires' cookie."); // esl return await setUserIdCookie(setCookies, userId, response);
}

if (cookieExpiryDate > new Date()) {
return response;
}
}

return await setUserIdCookie(setCookies, userId, response);
}

async function setUserIdCookie(
setCookies: (name: string, value: string) => Promise<void>,
userId: string,
response: NextResponse,
) {
await setCookies("userId", userId);

return response;
}
233 changes: 233 additions & 0 deletions apps/nextjs/src/app/api/discord/interactions/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
import type {
APIEmbed,
APIInteractionDataOptionBase,
ApplicationCommandOptionType,
} from "discord-api-types/v10";
import type { NextRequest } from "next/server";
import type Pokedex from "pokedex-promise-v2";
import { NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import {
InteractionResponseType,
InteractionType,
MessageFlags,
} from "discord-api-types/v10";
import { nanoid } from "nanoid";

import type { RandomPicType } from "~/lib/discord/commands";
import { env } from "~/env";
import { commands } from "~/lib/discord/commands";
import { verifyInteractionRequest } from "~/lib/discord/verify-incoming-request";

/**
* Use edge runtime which is faster, cheaper, and has no cold-boot.
* If you want to use node runtime, you can change this to `node`, but you'll also have to polyfill fetch (and maybe other things).
*
* @see https://nextjs.org/docs/app/building-your-application/rendering/edge-and-nodejs-runtimes
*/
export const runtime = "edge";

const ROOT_URL = env.VERCEL_URL ? `https://${env.VERCEL_URL}` : env.ROOT_URL;

function capitalizeFirstLetter(s: string) {
return s.charAt(0).toUpperCase() + s.slice(1);
}

/**
* Handle Discord interactions. Discord will send interactions to this endpoint.
*
* @see https://discord.com/developers/docs/interactions/receiving-and-responding#receiving-an-interaction
*/
export async function POST(request: NextRequest) {
const { userId } = await auth();
if (!userId) {
return NextResponse.json(
{ error: "Logged in account is required" },
{ status: 401 },
);
}

if (!env.DISCORD_APP_PUBLIC_KEY || !env.DISCORD_APP_ID) {
return new NextResponse(
"DISCORD_APP_PUBLIC_KEY or DISCORD_APP_ID not initialized",
{ status: 500 },
);
}

const verifyResult = await verifyInteractionRequest(
request,
env.DISCORD_APP_PUBLIC_KEY,
);

if (!verifyResult.isValid || !verifyResult.interaction) {
return new NextResponse("Invalid request", { status: 401 });
}
const { interaction } = verifyResult;

if (interaction.type === InteractionType.Ping) {
// The `PING` message is used during the initial webhook handshake, and is
// required to configure the webhook in the developer portal.
return NextResponse.json({ type: InteractionResponseType.Pong });
}

if (interaction.type === InteractionType.ApplicationCommand) {
const { name } = interaction.data;

switch (name) {
case commands.ping.name: {
return NextResponse.json({
type: InteractionResponseType.ChannelMessageWithSource,
data: { content: `Pong` },
});
}
case commands.invite.name: {
return NextResponse.json({
type: InteractionResponseType.ChannelMessageWithSource,
data: {
content: `Click this link to add NextBot to your server: https://discord.com/api/oauth2/authorize?client_id=${env.DISCORD_APP_ID}&permissions=2147485696&scope=bot%20applications.commands`,
flags: MessageFlags.Ephemeral,
},
});
}
case commands.pokemon.name: {
if (!interaction.data.options || interaction.data.options.length < 1) {
return NextResponse.json({
type: InteractionResponseType.ChannelMessageWithSource,
data: {
content: "Oops! Please enter a Pokemon name or Pokedex number.",
flags: MessageFlags.Ephemeral,
},
});
}

const option = interaction.data.options[0];

// @ts-expect-error copy pasta
const idOrName = String(option.value).toLowerCase();

try {
const pokemon: Pokedex.PokemonSpecies = await fetch(
`https://pokeapi.co/api/v2/pokemon/${idOrName}`,
).then((res) => {
return res.json();
});
const types = pokemon.types.reduce(
(prev: string[], curr: { type: { name: string } }) => [
...prev,
capitalizeFirstLetter(curr.type.name),
],
[],
);

return NextResponse.json({
type: InteractionResponseType.ChannelMessageWithSource,
data: {
embeds: [
{
title: capitalizeFirstLetter(pokemon.name),
image: {
url: `${ROOT_URL}/api/pokemon/${idOrName}`,
},
fields: [
{
name: "Pokedex",
value: `#${String(pokemon.id).padStart(3, "0")}`,
},
{
name: "Type",
value: types.join("/"),
},
],
},
],
},
});
} catch (error) {
console.error("error fetching pokemon", error);
throw new Error("Something went wrong :(");
}
}
case commands.randompic.name: {
const { options } = interaction.data;

if (!options) {
return new NextResponse("Invalid request", { status: 400 });
}

const { value } = options[0] as APIInteractionDataOptionBase<
ApplicationCommandOptionType.String,
RandomPicType
>;
const embed = await getRandomPic(value);

return NextResponse.json({
type: InteractionResponseType.ChannelMessageWithSource,
data: { embeds: [embed] },
});
}
default:
// Pass through, return error at end of function
}
}

return new NextResponse("Unknown command", { status: 400 });
}

const baseRandomPicEmbed = {
title: "Random Pic",
description: "Here's your random pic!",
};

/**
* @see https://discord.com/developers/docs/resources/channel#embed-object
*/
const createEmbedObject = (source: string, path: string): APIEmbed => {
return {
...baseRandomPicEmbed,
fields: [{ name: "source", value: source }],
image: {
url: `${source}${path}`,
},
};
};

/**
* Fetches a random picture and returns it as a Discord image embed.
*/
const getRandomPic = async (value: RandomPicType) => {
switch (value) {
case "cat": {
const catResponse = await fetch("https://cataas.com/cat?json=true");
const catData = await catResponse.json();
const { url: catUrl } = catData;

return {
...createEmbedObject("https://cataas.com", catUrl as string),
description: "Here's a random cat picture!",
};
}
case "dog": {
const dogResponse = await fetch(
"https://dog.ceo/api/breeds/image/random",
);
const dogData = await dogResponse.json();
const { message: dogUrl } = dogData;

return {
...baseRandomPicEmbed,
description: "Here's a random dog picture!",
fields: [{ name: "source", value: "https://dog.ceo/api" }],
image: { url: dogUrl },
};
}
default:
return createEmbedObject(
"https://picsum.photos",
`/seed/${nanoid()}/500`,
);
}
};
32 changes: 32 additions & 0 deletions apps/nextjs/src/app/api/pokemon/pokepaste/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// TODO: fix restrict-template-expressions
/* eslint-disable @typescript-eslint/restrict-template-expressions */
// TODO: fix no unsafe assignment
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";

export const runtime = "edge";

export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { url } = body;

if (!url || typeof url !== "string") {
return NextResponse.json(
{ error: "Invalid URL format" },
{ status: 400 },
);
}

const response = await fetch(url);
const text = await response.text();

return NextResponse.json({ data: text }, { status: 200 });
} catch (error) {
return NextResponse.json(
{ error: `Failed to fetch pokepaste ${error}` },
{ status: 500 },
);
}
}
Loading

1 comment on commit 77c8983

@vercel
Copy link

@vercel vercel bot commented on 77c8983 Nov 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.