From 188c714f43635fb57eac70b167dba682d6b93a2f Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sun, 7 Sep 2025 02:28:34 -0700 Subject: build: Switch to TypeScript --- src/commands.js | 55 ----------- src/commands.ts | 76 +++++++++++++++ src/reddit.js | 100 ------------------- src/reddit.ts | 146 +++++++++++++++++++++++++++ src/register.js | 59 ----------- src/register.ts | 62 ++++++++++++ src/server.js | 237 -------------------------------------------- src/server.ts | 298 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 582 insertions(+), 451 deletions(-) delete mode 100644 src/commands.js create mode 100644 src/commands.ts delete mode 100644 src/reddit.js create mode 100644 src/reddit.ts delete mode 100644 src/register.js create mode 100644 src/register.ts delete mode 100644 src/server.js create mode 100644 src/server.ts (limited to 'src') diff --git a/src/commands.js b/src/commands.js deleted file mode 100644 index c2faac5..0000000 --- a/src/commands.js +++ /dev/null @@ -1,55 +0,0 @@ -export const HOT_COMMAND = { - name: 'hot', - description: 'Fetch a random hot post from r/okbuddyumamusume', -}; - -export const ROLEPLAY_COMMAND = { - name: 'roleplay', - description: 'Fetch a random hot roleplay post from r/okbuddyumamusume', -}; - -export const NSFW_COMMAND = { - name: 'nsfw', - description: - 'Fetch a random NSFW post from r/okbuddyumamusume (NSFW channels only)', -}; - -export const TOP_COMMAND = { - name: 'top', - description: - 'Fetch a random top post from r/okbuddyumamusume (defaults to today)', - options: [ - { - type: 3, - name: 'time', - description: 'Time period for top posts (defaults to today)', - required: false, - choices: [ - { - name: 'Now', - value: 'hour', - }, - { - name: 'Today', - value: 'day', - }, - { - name: 'This Week', - value: 'week', - }, - { - name: 'This Month', - value: 'month', - }, - { - name: 'This Year', - value: 'year', - }, - { - name: 'All Time', - value: 'all', - }, - ], - }, - ], -}; diff --git a/src/commands.ts b/src/commands.ts new file mode 100644 index 0000000..56d4321 --- /dev/null +++ b/src/commands.ts @@ -0,0 +1,76 @@ +export interface DiscordCommand { + name: string; + description: string; + options?: DiscordCommandOption[]; +} + +export interface DiscordCommandOption { + type: number; + name: string; + description: string; + required?: boolean; + choices?: DiscordCommandChoice[]; +} + +export interface DiscordCommandChoice { + name: string; + value: string; +} + +export type TimePeriod = 'hour' | 'day' | 'week' | 'month' | 'year' | 'all'; + +export const HOT_COMMAND: DiscordCommand = { + name: 'hot', + description: 'Fetch a random hot post from r/okbuddyumamusume', +}; + +export const ROLEPLAY_COMMAND: DiscordCommand = { + name: 'roleplay', + description: 'Fetch a random hot roleplay post from r/okbuddyumamusume', +}; + +export const NSFW_COMMAND: DiscordCommand = { + name: 'nsfw', + description: + 'Fetch a random NSFW post from r/okbuddyumamusume (NSFW channels only)', +}; + +export const TOP_COMMAND: DiscordCommand = { + name: 'top', + description: + 'Fetch a random top post from r/okbuddyumamusume (defaults to today)', + options: [ + { + type: 3, + name: 'time', + description: 'Time period for top posts (defaults to today)', + required: false, + choices: [ + { + name: 'Now', + value: 'hour', + }, + { + name: 'Today', + value: 'day', + }, + { + name: 'This Week', + value: 'week', + }, + { + name: 'This Month', + value: 'month', + }, + { + name: 'This Year', + value: 'year', + }, + { + name: 'All Time', + value: 'all', + }, + ], + }, + ], +}; diff --git a/src/reddit.js b/src/reddit.js deleted file mode 100644 index 543e6ff..0000000 --- a/src/reddit.js +++ /dev/null @@ -1,100 +0,0 @@ -async function fetchRedditPosts(sort = 'hot', time = 'day') { - const url = `https://www.reddit.com/r/okbuddyumamusume/${sort}.json${sort === 'top' ? `?t=${time}` : ''}`; - const response = await fetch(url, { - headers: { - 'User-Agent': 'UmaBot/0.1.0', - }, - }); - - if (!response.ok) { - let errorText = `Error fetching ${response.url}: ${response.status} ${response.statusText}`; - - try { - const error = await response.text(); - - if (error) errorText = `${errorText} \n\n ${error}`; - } catch { - // - } - - throw new Error(errorText); - } - - const data = await response.json(); - - return data.data.children.map((post) => post.data); -} - -function filterPostsByFlair(posts, excludedFlairs = [], includedFlairs = []) { - return posts.filter((post) => { - if (post.is_gallery) return false; - - const hasMedia = - post.media?.reddit_video?.fallback_url || - post.secure_media?.reddit_video?.fallback_url || - post.url; - - if (!hasMedia) return false; - - const postFlair = post.link_flair_text?.toLowerCase() || ''; - const isNSFW = post.over_18 || postFlair.includes('nsfw'); - - if ( - includedFlairs.length > 0 && - includedFlairs.some((flair) => flair.toLowerCase() === 'nsfw') - ) - if (includedFlairs.some((flair) => flair.toLowerCase() === 'nsfw')) - return isNSFW; - - if (isNSFW) return false; - - if (includedFlairs.length > 0) - return includedFlairs.some((flair) => - postFlair.includes(flair.toLowerCase()), - ); - - if (excludedFlairs.length > 0) - return !excludedFlairs.some((flair) => - postFlair.includes(flair.toLowerCase()), - ); - - return true; - }); -} - -function getRandomPost(posts) { - if (posts.length === 0) - throw new Error('No posts found matching the criteria'); - - const randomIndex = Math.floor(Math.random() * posts.length); - - return posts[randomIndex]; -} - -export async function getCutePost() { - const posts = await fetchRedditPosts('hot'); - const filteredPosts = filterPostsByFlair(posts, ['roleplay', 'announcement']); - - return getRandomPost(filteredPosts); -} - -export async function getRoleplayPost() { - const posts = await fetchRedditPosts('hot'); - const filteredPosts = filterPostsByFlair(posts, [], ['roleplay']); - - return getRandomPost(filteredPosts); -} - -export async function getNSFWPost() { - const posts = await fetchRedditPosts('hot'); - const filteredPosts = filterPostsByFlair(posts, [], ['nsfw']); - - return getRandomPost(filteredPosts); -} - -export async function getTopPost(time = 'day') { - const posts = await fetchRedditPosts('top', time); - const filteredPosts = filterPostsByFlair(posts, ['roleplay', 'announcement']); - - return getRandomPost(filteredPosts); -} diff --git a/src/reddit.ts b/src/reddit.ts new file mode 100644 index 0000000..79475b1 --- /dev/null +++ b/src/reddit.ts @@ -0,0 +1,146 @@ +import type { TimePeriod } from './commands.js'; + +export interface RedditPost { + id: string; + title: string; + author: string; + score: number; + num_comments: number; + created_utc: number; + permalink: string; + url: string; + selftext: string; + is_gallery?: boolean; + over_18: boolean; + link_flair_text?: string; + media?: { + reddit_video?: { + fallback_url: string; + }; + }; + secure_media?: { + reddit_video?: { + fallback_url: string; + }; + }; +} + +export interface RedditResponse { + data: { + children: Array<{ + data: RedditPost; + }>; + }; +} + +type SortType = 'hot' | 'top'; + +async function fetchRedditPosts( + sort: SortType = 'hot', + time: TimePeriod = 'day', +): Promise { + const url = `https://www.reddit.com/r/okbuddyumamusume/${sort}.json${sort === 'top' ? `?t=${time}` : ''}`; + const response = await fetch(url, { + headers: { + 'User-Agent': 'UmaBot/0.1.0', + }, + }); + + if (!response.ok) { + let errorText = `Error fetching ${response.url}: ${response.status} ${response.statusText}`; + + try { + const error = await response.text(); + + if (error) errorText = `${errorText} \n\n ${error}`; + } catch { + // + } + + throw new Error(errorText); + } + + const data: RedditResponse = await response.json(); + + return data.data.children.map((post) => post.data); +} + +function filterPostsByFlair( + posts: RedditPost[], + excludedFlairs: string[] = [], + includedFlairs: string[] = [], +): RedditPost[] { + return posts.filter((post) => { + if (post.is_gallery) return false; + + const hasMedia = + post.media?.reddit_video?.fallback_url || + post.secure_media?.reddit_video?.fallback_url || + post.url; + + if (!hasMedia) return false; + + const postFlair = post.link_flair_text?.toLowerCase() || ''; + const isNSFW = post.over_18 || postFlair.includes('nsfw'); + + if ( + includedFlairs.length > 0 && + includedFlairs.some((flair) => flair.toLowerCase() === 'nsfw') + ) + if (includedFlairs.some((flair) => flair.toLowerCase() === 'nsfw')) + return isNSFW; + + if (isNSFW) return false; + + if (includedFlairs.length > 0) + return includedFlairs.some((flair) => + postFlair.includes(flair.toLowerCase()), + ); + + if (excludedFlairs.length > 0) + return !excludedFlairs.some((flair) => + postFlair.includes(flair.toLowerCase()), + ); + + return true; + }); +} + +function getRandomPost(posts: RedditPost[]): RedditPost { + if (posts.length === 0) + throw new Error('No posts found matching the criteria'); + + const randomIndex = Math.floor(Math.random() * posts.length); + + return posts[randomIndex]; +} + +export async function getCutePost(): Promise { + const posts = await fetchRedditPosts('hot'); + const filteredPosts = filterPostsByFlair(posts, ['roleplay', 'announcement']); + + return getRandomPost(filteredPosts); +} + +export async function getRoleplayPost(): Promise { + const posts = await fetchRedditPosts('hot'); + const filteredPosts = filterPostsByFlair(posts, [], ['roleplay']); + + return getRandomPost(filteredPosts); +} + +export async function getNSFWPost(): Promise { + const posts = await fetchRedditPosts('hot'); + const filteredPosts = filterPostsByFlair(posts, [], ['nsfw']); + + return getRandomPost(filteredPosts); +} + +export async function getTopPost( + time: TimePeriod = 'day', +): Promise { + const posts = await fetchRedditPosts('top', time); + const filteredPosts = filterPostsByFlair(posts, ['roleplay', 'announcement']); + + return getRandomPost(filteredPosts); +} diff --git a/src/register.js b/src/register.js deleted file mode 100644 index 5b374d1..0000000 --- a/src/register.js +++ /dev/null @@ -1,59 +0,0 @@ -import { - HOT_COMMAND, - NSFW_COMMAND, - ROLEPLAY_COMMAND, - TOP_COMMAND, -} from './commands.js'; -import dotenv from 'dotenv'; -import process from 'node:process'; - -dotenv.config({ path: '.dev.vars' }); - -const token = process.env.DISCORD_TOKEN; -const applicationID = process.env.DISCORD_APPLICATION_ID; - -if (!token) - throw new Error('The DISCORD_TOKEN environment variable is required.'); - -if (!applicationID) - throw new Error( - 'The DISCORD_APPLICATION_ID environment variable is required.', - ); - -const url = `https://discord.com/api/v10/applications/${applicationID}/commands`; - -const response = await fetch(url, { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bot ${token}`, - }, - method: 'PUT', - body: JSON.stringify([ - HOT_COMMAND, - ROLEPLAY_COMMAND, - NSFW_COMMAND, - TOP_COMMAND, - ]), -}); - -if (response.ok) { - console.log('Registered all commands'); - - const data = await response.json(); - - console.log(JSON.stringify(data, null, 2)); -} else { - console.error('Error registering commands'); - - let errorText = `Error registering commands \n ${response.url}: ${response.status} ${response.statusText}`; - - try { - const error = await response.text(); - - if (error) errorText = `${errorText} \n\n ${error}`; - } catch (error) { - console.error('Error reading body from request:', error); - } - - console.error(errorText); -} diff --git a/src/register.ts b/src/register.ts new file mode 100644 index 0000000..632b2b8 --- /dev/null +++ b/src/register.ts @@ -0,0 +1,62 @@ +import { + HOT_COMMAND, + NSFW_COMMAND, + ROLEPLAY_COMMAND, + TOP_COMMAND, + type DiscordCommand, +} from './commands.js'; +import dotenv from 'dotenv'; +import process from 'node:process'; + +dotenv.config({ path: '.dev.vars' }); + +const token = process.env.DISCORD_TOKEN; +const applicationID = process.env.DISCORD_APPLICATION_ID; + +if (!token) + throw new Error('The DISCORD_TOKEN environment variable is required.'); + +if (!applicationID) + throw new Error( + 'The DISCORD_APPLICATION_ID environment variable is required.', + ); + +const url = `https://discord.com/api/v10/applications/${applicationID}/commands`; + +const commands: DiscordCommand[] = [ + HOT_COMMAND, + ROLEPLAY_COMMAND, + NSFW_COMMAND, + TOP_COMMAND, +]; + +const response = await fetch(url, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bot ${token}`, + }, + method: 'PUT', + body: JSON.stringify(commands), +}); + +if (response.ok) { + console.log('Registered all commands'); + + const data = await response.json(); + + console.log(JSON.stringify(data, null, 2)); +} else { + console.error('Error registering commands'); + + let errorText = `Error registering commands \n ${response.url}: ${response.status} ${response.statusText}`; + + try { + const error = await response.text(); + + if (error) errorText = `${errorText} \n\n ${error}`; + } catch (error) { + console.error('Error reading body from request:', error); + } + + console.error(errorText); +} diff --git a/src/server.js b/src/server.js deleted file mode 100644 index 22b754c..0000000 --- a/src/server.js +++ /dev/null @@ -1,237 +0,0 @@ -import { AutoRouter } from 'itty-router'; -import { - InteractionResponseType, - InteractionType, - verifyKey, -} from 'discord-interactions'; -import { - HOT_COMMAND, - ROLEPLAY_COMMAND, - NSFW_COMMAND, - TOP_COMMAND, -} from './commands.js'; -import { - getCutePost, - getRoleplayPost, - getNSFWPost, - getTopPost, -} from './reddit.js'; - -class JSONResponse extends Response { - constructor(body, init) { - const jsonBody = JSON.stringify(body); - - init = init || { - headers: { - 'content-type': 'application/json;charset=UTF-8', - }, - }; - - super(jsonBody, init); - } -} - -const router = AutoRouter(); - -function createPostEmbed(post) { - const mediaUrl = - post.media?.reddit_video?.fallback_url || - post.secure_media?.reddit_video?.fallback_url || - post.url; - - let description = post.selftext || ''; - - if (description.length > 1000) - description = description.substring(0, 997).trim() + ' ...'; - - const embed = { - title: post.title, - description: description, - url: `https://reddit.com${post.permalink}`, - color: 0xff4500, - author: { - name: `u/${post.author}`, - url: `https://reddit.com/u/${post.author}`, - }, - fields: [ - { - name: 'Score', - value: `${post.score} ⬆️`, - inline: true, - }, - { - name: 'Comments', - value: `${post.num_comments} 💬`, - inline: true, - }, - ], - timestamp: new Date(post.created_utc * 1000).toISOString(), - footer: { - text: 'r/okbuddyumamusume', - }, - }; - - if (mediaUrl) - if (post.media?.reddit_video || post.secure_media?.reddit_video) - embed.video = { url: mediaUrl }; - else embed.image = { url: mediaUrl }; - - return embed; -} - -router.get('/', (_request, environment) => { - return new Response(`👋 ${environment.DISCORD_APPLICATION_ID}`); -}); - -router.post('/', async (request, environment) => { - const { isValid, interaction } = await server.verifyDiscordRequest( - request, - environment, - ); - - if (!isValid || !interaction) - return new Response('Bad request signature.', { status: 401 }); - - if (interaction.type === InteractionType.PING) - return new JSONResponse({ - type: InteractionResponseType.PONG, - }); - - if (interaction.type === InteractionType.APPLICATION_COMMAND) { - switch (interaction.data.name.toLowerCase()) { - case HOT_COMMAND.name.toLowerCase(): { - try { - const post = await getCutePost(); - const embed = createPostEmbed(post); - - return new JSONResponse({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - embeds: [embed], - }, - }); - } catch { - return new JSONResponse({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - content: '❌ No posts found. Try again later!', - flags: 64, - }, - }); - } - } - - case ROLEPLAY_COMMAND.name.toLowerCase(): { - try { - const post = await getRoleplayPost(); - const embed = createPostEmbed(post); - - return new JSONResponse({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - embeds: [embed], - }, - }); - } catch { - return new JSONResponse({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - content: '❌ No roleplay posts found. Try again later!', - flags: 64, - }, - }); - } - } - - case NSFW_COMMAND.name.toLowerCase(): { - if (!interaction.channel_id || !interaction.channel?.nsfw) { - return new JSONResponse({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - content: '❌ This command can only be used in NSFW channels.', - flags: 64, - }, - }); - } - - try { - const post = await getNSFWPost(); - const embed = createPostEmbed(post); - - return new JSONResponse({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - embeds: [embed], - }, - }); - } catch { - return new JSONResponse({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - content: '❌ No NSFW posts found. Try again later!', - flags: 64, - }, - }); - } - } - - case TOP_COMMAND.name.toLowerCase(): { - try { - const time = interaction.data.options?.[0]?.value || 'day'; - const post = await getTopPost(time); - const embed = createPostEmbed(post); - - return new JSONResponse({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - embeds: [embed], - }, - }); - } catch { - return new JSONResponse({ - type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - content: '❌ No top posts found. Try again later!', - flags: 64, - }, - }); - } - } - - default: - return new JSONResponse({ error: 'Unknown Type' }, { status: 400 }); - } - } - - console.error('Unknown Type'); - - return new JSONResponse({ error: 'Unknown Type' }, { status: 400 }); -}); - -router.all('*', () => new Response('Not Found.', { status: 404 })); - -async function verifyDiscordRequest(request, environment) { - const signature = request.headers.get('x-signature-ed25519'); - const timestamp = request.headers.get('x-signature-timestamp'); - const body = await request.text(); - const isValidRequest = - signature && - timestamp && - (await verifyKey( - body, - signature, - timestamp, - environment.DISCORD_PUBLIC_KEY, - )); - - if (!isValidRequest) return { isValid: false }; - - return { interaction: JSON.parse(body), isValid: true }; -} - -const server = { - verifyDiscordRequest, - fetch: router.fetch, -}; - -export default server; diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..3f54f63 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,298 @@ +import { AutoRouter } from 'itty-router'; +import { + InteractionResponseType, + InteractionType, + verifyKey, +} from 'discord-interactions'; +import { + HOT_COMMAND, + ROLEPLAY_COMMAND, + NSFW_COMMAND, + TOP_COMMAND, +} from './commands.js'; +import { + getCutePost, + getRoleplayPost, + getNSFWPost, + getTopPost, + type RedditPost, +} from './reddit.js'; +import type { TimePeriod } from './commands.js'; + +interface Environment { + DISCORD_APPLICATION_ID: string; + DISCORD_PUBLIC_KEY: string; + DISCORD_TOKEN: string; +} + +interface DiscordInteraction { + type: number; + data: { + name: string; + options?: Array<{ + name: string; + value: string; + }>; + }; + channel_id?: string; + channel?: { + nsfw: boolean; + }; +} + +interface DiscordEmbed { + title: string; + description: string; + url: string; + color: number; + author: { + name: string; + url: string; + }; + fields: Array<{ + name: string; + value: string; + inline: boolean; + }>; + timestamp: string; + footer: { + text: string; + }; + video?: { url: string }; + image?: { url: string }; +} + +interface DiscordResponse { + type: number; + data?: { + content?: string; + embeds?: DiscordEmbed[]; + flags?: number; + }; +} + +class JSONResponse extends Response { + constructor(body: DiscordResponse | { error: string }, init?: ResponseInit) { + const jsonBody = JSON.stringify(body); + + init = init || { + headers: { + 'content-type': 'application/json;charset=UTF-8', + }, + }; + + super(jsonBody, init); + } +} + +const router = AutoRouter(); + +function createPostEmbed(post: RedditPost): DiscordEmbed { + const mediaUrl = + post.media?.reddit_video?.fallback_url || + post.secure_media?.reddit_video?.fallback_url || + post.url; + + let description = post.selftext || ''; + + if (description.length > 1000) + description = description.substring(0, 997).trim() + ' ...'; + + const embed: DiscordEmbed = { + title: post.title, + description: description, + url: `https://reddit.com${post.permalink}`, + color: 0xff4500, + author: { + name: `u/${post.author}`, + url: `https://reddit.com/u/${post.author}`, + }, + fields: [ + { + name: 'Score', + value: `${post.score} ⬆️`, + inline: true, + }, + { + name: 'Comments', + value: `${post.num_comments} 💬`, + inline: true, + }, + ], + timestamp: new Date(post.created_utc * 1000).toISOString(), + footer: { + text: 'r/okbuddyumamusume', + }, + }; + + if (mediaUrl) { + if (post.media?.reddit_video || post.secure_media?.reddit_video) { + embed.video = { url: mediaUrl }; + } else { + embed.image = { url: mediaUrl }; + } + } + + return embed; +} + +router.get('/', (_request: Request, environment: Environment) => { + return new Response(`👋 ${environment.DISCORD_APPLICATION_ID}`); +}); + +router.post('/', async (request: Request, environment: Environment) => { + const { isValid, interaction } = await server.verifyDiscordRequest( + request, + environment, + ); + + if (!isValid || !interaction) + return new Response('Bad request signature.', { status: 401 }); + + if (interaction.type === InteractionType.PING) + return new JSONResponse({ + type: InteractionResponseType.PONG, + }); + + if (interaction.type === InteractionType.APPLICATION_COMMAND) { + switch (interaction.data.name.toLowerCase()) { + case HOT_COMMAND.name.toLowerCase(): { + try { + const post = await getCutePost(); + const embed = createPostEmbed(post); + + return new JSONResponse({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + embeds: [embed], + }, + }); + } catch { + return new JSONResponse({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: '❌ No posts found. Try again later!', + flags: 64, + }, + }); + } + } + + case ROLEPLAY_COMMAND.name.toLowerCase(): { + try { + const post = await getRoleplayPost(); + const embed = createPostEmbed(post); + + return new JSONResponse({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + embeds: [embed], + }, + }); + } catch { + return new JSONResponse({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: '❌ No roleplay posts found. Try again later!', + flags: 64, + }, + }); + } + } + + case NSFW_COMMAND.name.toLowerCase(): { + if (!interaction.channel_id || !interaction.channel?.nsfw) { + return new JSONResponse({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: '❌ This command can only be used in NSFW channels.', + flags: 64, + }, + }); + } + + try { + const post = await getNSFWPost(); + const embed = createPostEmbed(post); + + return new JSONResponse({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + embeds: [embed], + }, + }); + } catch { + return new JSONResponse({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: '❌ No NSFW posts found. Try again later!', + flags: 64, + }, + }); + } + } + + case TOP_COMMAND.name.toLowerCase(): { + try { + const time = + (interaction.data.options?.[0]?.value as TimePeriod) || 'day'; + const post = await getTopPost(time); + const embed = createPostEmbed(post); + + return new JSONResponse({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + embeds: [embed], + }, + }); + } catch { + return new JSONResponse({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: '❌ No top posts found. Try again later!', + flags: 64, + }, + }); + } + } + + default: + return new JSONResponse({ error: 'Unknown Type' }, { status: 400 }); + } + } + + console.error('Unknown Type'); + + return new JSONResponse({ error: 'Unknown Type' }, { status: 400 }); +}); + +router.all('*', () => new Response('Not Found.', { status: 404 })); + +async function verifyDiscordRequest( + request: Request, + environment: Environment, +): Promise<{ isValid: boolean; interaction?: DiscordInteraction }> { + const signature = request.headers.get('x-signature-ed25519'); + const timestamp = request.headers.get('x-signature-timestamp'); + const body = await request.text(); + const isValidRequest = + signature && + timestamp && + (await verifyKey( + body, + signature, + timestamp, + environment.DISCORD_PUBLIC_KEY, + )); + + if (!isValidRequest) return { isValid: false }; + + return { interaction: JSON.parse(body) as DiscordInteraction, isValid: true }; +} + +const server = { + verifyDiscordRequest, + fetch: router.fetch, +}; + +export default server; -- cgit v1.2.3