From 9678e4e1e87a5d73c47683fe85848888ca8e857b Mon Sep 17 00:00:00 2001 From: Fuwn Date: Tue, 9 Sep 2025 18:05:15 -0700 Subject: refactor: Move Discord APIs to Discord module --- src/commands.ts | 76 ------------------ src/discord/commands.ts | 59 ++++++++++++++ src/discord/embeds.ts | 90 +++++++++++++++++++++ src/discord/interfaces.ts | 69 ++++++++++++++++ src/discord/responses.ts | 15 ++++ src/discord/types.ts | 1 + src/discord/verification.ts | 24 ++++++ src/reddit.ts | 2 +- src/register.ts | 2 +- src/server.ts | 190 ++------------------------------------------ 10 files changed, 267 insertions(+), 261 deletions(-) delete mode 100644 src/commands.ts create mode 100644 src/discord/commands.ts create mode 100644 src/discord/embeds.ts create mode 100644 src/discord/interfaces.ts create mode 100644 src/discord/responses.ts create mode 100644 src/discord/types.ts create mode 100644 src/discord/verification.ts diff --git a/src/commands.ts b/src/commands.ts deleted file mode 100644 index 56d4321..0000000 --- a/src/commands.ts +++ /dev/null @@ -1,76 +0,0 @@ -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/discord/commands.ts b/src/discord/commands.ts new file mode 100644 index 0000000..dec18e6 --- /dev/null +++ b/src/discord/commands.ts @@ -0,0 +1,59 @@ +import type { DiscordCommand } from './interfaces.ts'; + +export type { DiscordCommand }; + +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/discord/embeds.ts b/src/discord/embeds.ts new file mode 100644 index 0000000..f88cec2 --- /dev/null +++ b/src/discord/embeds.ts @@ -0,0 +1,90 @@ +import type { DiscordEmbed } from './interfaces.ts'; +import type { RedditPost } from '../reddit.ts'; + +function decodeHtmlEntities(str: string): string { + return str + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(///g, '/') + .replace(/`/g, '`') + .replace(/=/g, '='); +} + +export 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) { + if (!description) description = ''; + + description += + '\n\nšŸ“¹ **This post contains a video** - [Click here to view](' + + mediaUrl + + ')'; + embed.description = description; + + if (post.preview?.images?.[0]?.source?.url) { + const decodedURL = decodeHtmlEntities( + post.preview.images[0].source.url, + ); + + console.log('Using preview image:', decodedURL); + + embed.image = { url: decodedURL }; + } else if ( + post.thumbnail && + post.thumbnail !== 'self' && + post.thumbnail !== 'default' + ) { + const decodedThumbnail = decodeHtmlEntities(post.thumbnail); + + console.log('Using thumbnail:', decodedThumbnail); + + embed.image = { url: decodedThumbnail }; + } else { + console.log('No suitable thumbnail found for video post'); + } + } else { + embed.image = { url: mediaUrl }; + } + + return embed; +} diff --git a/src/discord/interfaces.ts b/src/discord/interfaces.ts new file mode 100644 index 0000000..3eb81eb --- /dev/null +++ b/src/discord/interfaces.ts @@ -0,0 +1,69 @@ +export interface Environment { + DISCORD_APPLICATION_ID: string; + DISCORD_PUBLIC_KEY: string; + DISCORD_TOKEN: string; +} + +export interface DiscordInteraction { + type: number; + data: { + name: string; + options?: Array<{ + name: string; + value: string; + }>; + }; + channel_id?: string; + channel?: { + nsfw: boolean; + }; +} + +export 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; + }; + image?: { url: string }; +} + +export interface DiscordResponse { + type: number; + data?: { + content?: string; + embeds?: DiscordEmbed[]; + flags?: number; + }; +} + +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; +} diff --git a/src/discord/responses.ts b/src/discord/responses.ts new file mode 100644 index 0000000..da72967 --- /dev/null +++ b/src/discord/responses.ts @@ -0,0 +1,15 @@ +import type { DiscordResponse } from './interfaces.ts'; + +export 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); + } +} diff --git a/src/discord/types.ts b/src/discord/types.ts new file mode 100644 index 0000000..9b1d6c5 --- /dev/null +++ b/src/discord/types.ts @@ -0,0 +1 @@ +export type TimePeriod = 'hour' | 'day' | 'week' | 'month' | 'year' | 'all'; diff --git a/src/discord/verification.ts b/src/discord/verification.ts new file mode 100644 index 0000000..c60c70e --- /dev/null +++ b/src/discord/verification.ts @@ -0,0 +1,24 @@ +import { verifyKey } from 'discord-interactions'; +import type { Environment, DiscordInteraction } from './interfaces.ts'; + +export 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 }; +} diff --git a/src/reddit.ts b/src/reddit.ts index 6fe576e..0c12739 100644 --- a/src/reddit.ts +++ b/src/reddit.ts @@ -1,4 +1,4 @@ -import type { TimePeriod } from './commands.ts'; +import type { TimePeriod } from './discord/types.ts'; export interface RedditPost { id: string; diff --git a/src/register.ts b/src/register.ts index 94bb420..319b054 100644 --- a/src/register.ts +++ b/src/register.ts @@ -4,7 +4,7 @@ import { ROLEPLAY_COMMAND, TOP_COMMAND, type DiscordCommand, -} from './commands.ts'; +} from './discord/commands.ts'; import dotenv from 'dotenv'; import process from 'node:process'; diff --git a/src/server.ts b/src/server.ts index baeb607..5f43763 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,179 +1,25 @@ import { AutoRouter } from 'itty-router'; -import { - InteractionResponseType, - InteractionType, - verifyKey, -} from 'discord-interactions'; +import { InteractionResponseType, InteractionType } from 'discord-interactions'; import { HOT_COMMAND, ROLEPLAY_COMMAND, NSFW_COMMAND, TOP_COMMAND, -} from './commands.ts'; +} from './discord/commands.ts'; import { getCutePost, getRoleplayPost, getNSFWPost, getTopPost, - type RedditPost, } from './reddit.ts'; -import type { TimePeriod } from './commands.ts'; - -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; - }; - 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); - } -} +import type { TimePeriod } from './discord/types.ts'; +import type { Environment } from './discord/interfaces.ts'; +import { createPostEmbed } from './discord/embeds.ts'; +import { JSONResponse } from './discord/responses.ts'; +import { verifyDiscordRequest } from './discord/verification.ts'; const router = AutoRouter(); -function decodeHtmlEntities(str: string): string { - return str - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(///g, '/') - .replace(/`/g, '`') - .replace(/=/g, '='); -} - -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) { - if (!description) description = ''; - - description += - '\n\nšŸ“¹ **This post contains a video** - [Click here to view](' + - mediaUrl + - ')'; - embed.description = description; - - if (post.preview?.images?.[0]?.source?.url) { - const decodedURL = decodeHtmlEntities( - post.preview.images[0].source.url, - ); - - console.log('Using preview image:', decodedURL); - - embed.image = { url: decodedURL }; - } else if ( - post.thumbnail && - post.thumbnail !== 'self' && - post.thumbnail !== 'default' - ) { - const decodedThumbnail = decodeHtmlEntities(post.thumbnail); - - console.log('Using thumbnail:', decodedThumbnail); - - embed.image = { url: decodedThumbnail }; - } else { - console.log('No suitable thumbnail found for video post'); - } - } else { - embed.image = { url: mediaUrl }; - } - - return embed; -} - router.get('/', (_request: Request, environment: Environment) => { return new Response(`šŸ‘‹ ${environment.DISCORD_APPLICATION_ID}`); }); @@ -314,28 +160,6 @@ router.post('/', async (request: Request, environment: Environment) => { 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, -- cgit v1.2.3