summaryrefslogtreecommitdiff
path: root/src/server.ts
diff options
context:
space:
mode:
authorFuwn <[email protected]>2025-09-07 02:28:34 -0700
committerFuwn <[email protected]>2025-09-07 02:28:34 -0700
commit188c714f43635fb57eac70b167dba682d6b93a2f (patch)
tree28a5bc64a6a8efd78c19cdaa666b98e42d3b90b5 /src/server.ts
parentfeat: Add top command (diff)
downloadumabotdiscord-188c714f43635fb57eac70b167dba682d6b93a2f.tar.xz
umabotdiscord-188c714f43635fb57eac70b167dba682d6b93a2f.zip
build: Switch to TypeScript
Diffstat (limited to 'src/server.ts')
-rw-r--r--src/server.ts298
1 files changed, 298 insertions, 0 deletions
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;