import { AutoRouter } from 'itty-router'; import { InteractionResponseType, InteractionType, verifyKey, } from 'discord-interactions'; import { HOT_COMMAND, ROLEPLAY_COMMAND, NSFW_COMMAND, TOP_COMMAND, } from './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; }; 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 (error) { console.error('Error in hot command:', error); 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 (error) { console.error('Error in roleplay command:', error); 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 (error) { console.error('Error in NSFW command:', error); 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 (error) { console.error('Error in top command:', error); 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;