diff --git a/eslint.config.mjs b/eslint.config.mjs index dfacb92..26bf637 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -17,4 +17,7 @@ export default tseslint.config( }, }, }, + { + ignores: [".wrangler/*", "dist/*", "patches/*"], + }, ); diff --git a/src/commands/base/base.mts b/src/commands/base/base.mts index 820e7e5..44699d9 100644 --- a/src/commands/base/base.mts +++ b/src/commands/base/base.mts @@ -1,16 +1,9 @@ import { Services } from "../../services/install.mjs"; -import { - APIApplicationCommandInteraction, - APIApplicationCommand, - RESTPostAPIWebhookWithTokenJSONBody, -} from "discord-api-types/v10"; +import { APIApplicationCommandInteraction, APIApplicationCommand, APIInteractionResponse } from "discord-api-types/v10"; export interface ExecuteResponse { - response: Omit< - RESTPostAPIWebhookWithTokenJSONBody, - "username" | "avatar_url" | "thread_name" | "tts" | "applied_tags" - >; - deferred: boolean; + response: APIInteractionResponse; + jobToComplete?: Promise; } export abstract class BaseCommand { @@ -18,5 +11,5 @@ export abstract class BaseCommand { abstract data: Omit; - abstract execute(interaction: APIApplicationCommandInteraction): Promise; + abstract execute(interaction: APIApplicationCommandInteraction): ExecuteResponse; } diff --git a/src/commands/stats/stats.mts b/src/commands/stats/stats.mts index 478556e..f644070 100644 --- a/src/commands/stats/stats.mts +++ b/src/commands/stats/stats.mts @@ -5,6 +5,7 @@ import { APIEmbed, ApplicationCommandOptionType, ApplicationCommandType, + InteractionResponseType, MessageFlags, } from "discord-api-types/v10"; import { GameVariantCategory, MatchStats } from "halo-infinite-api"; @@ -86,7 +87,7 @@ export class StatsCommand extends BaseCommand { ], }; - async execute(interaction: APIApplicationCommandInteraction): Promise { + execute(interaction: APIApplicationCommandInteraction): ExecuteResponse { try { const subcommand = this.services.discordService.extractSubcommand(interaction, "stats"); @@ -95,10 +96,10 @@ export class StatsCommand extends BaseCommand { } switch (subcommand.name) { case "neatqueue": { - return await this.handleNeatQueueSubCommand(interaction, subcommand.mappedOptions); + return this.handleNeatQueueSubCommand(interaction, subcommand.mappedOptions); } case "match": - return await this.handleMatchSubCommand(interaction, subcommand.mappedOptions); + return this.handleMatchSubCommand(interaction, subcommand.mappedOptions); default: throw new Error("Unknown subcommand"); } @@ -107,28 +108,40 @@ export class StatsCommand extends BaseCommand { return { response: { - content: `Error: ${error instanceof Error ? error.message : "unknown"}`, - flags: MessageFlags.Ephemeral, + type: InteractionResponseType.ChannelMessageWithSource, + data: { + content: `Error: ${error instanceof Error ? error.message : "unknown"}`, + flags: MessageFlags.Ephemeral, + }, }, - deferred: false, }; } } - private async handleNeatQueueSubCommand( + private handleNeatQueueSubCommand( interaction: APIApplicationCommandInteraction, options: Map, - ): Promise { + ): ExecuteResponse { + const { discordService } = this.services; + const channel = Preconditions.checkExists(options.get("channel") as string, "Missing channel"); const queue = Preconditions.checkExists(options.get("queue") as number, "Missing queue"); const ephemeral = (options.get("private") as boolean | undefined) ?? false; + + return { + response: discordService.getAcknowledgeResponse(ephemeral), + jobToComplete: this.neatQueueSubCommandJob(interaction, channel, queue), + }; + } + + private async neatQueueSubCommandJob( + interaction: APIApplicationCommandInteraction, + channel: string, + queue: number, + ): Promise { const { discordService } = this.services; - let deferred = false; try { - await this.services.discordService.acknowledgeInteraction(interaction, ephemeral); - deferred = true; - const queueData = await discordService.getTeamsFromQueue(channel, queue); if (!queueData) { throw new Error( @@ -145,10 +158,9 @@ export class StatsCommand extends BaseCommand { series, ); - const response: ExecuteResponse["response"] = { + await discordService.updateDeferredReply(interaction.token, { embeds: [seriesEmbed], - }; - await discordService.updateDeferredReply(interaction.token, response); + }); const message = await discordService.getMessageFromInteractionToken(interaction.token); const thread = await discordService.startThreadFromMessage( @@ -163,70 +175,49 @@ export class StatsCommand extends BaseCommand { await discordService.createMessage(thread.id, { embeds: [embed] }); } - - return { - response, - deferred, - }; } catch (error) { - console.error(error); - - return { - response: { - content: `Failed to fetch (Channel: <#${channel}>, queue: ${queue.toString()}): ${error instanceof Error ? error.message : "unknown"}`, - flags: MessageFlags.Ephemeral, - }, - deferred, - }; + await discordService.updateDeferredReply(interaction.token, { + content: `Failed to fetch (Channel: <#${channel}>, queue: ${queue.toString()}): ${error instanceof Error ? error.message : "unknown"}`, + }); } } - private async handleMatchSubCommand( + private handleMatchSubCommand( interaction: APIApplicationCommandInteraction, options: Map, - ): Promise { + ): ExecuteResponse { + const { discordService } = this.services; + const matchId = Preconditions.checkExists(options.get("id") as string, "Missing match id"); const ephemeral = (options.get("private") as boolean | undefined) ?? false; - let deferred = false; - try { - await this.services.discordService.acknowledgeInteraction(interaction, ephemeral); - deferred = true; + return { + response: discordService.getAcknowledgeResponse(ephemeral), + jobToComplete: this.matchSubCommandJob(interaction, matchId), + }; + } - const matches = await this.services.haloService.getMatchDetails([matchId]); + private async matchSubCommandJob(interaction: APIApplicationCommandInteraction, matchId: string): Promise { + const { discordService, haloService } = this.services; + try { + const matches = await haloService.getMatchDetails([matchId]); if (!matches.length) { - return { - response: { - content: "Match not found", - flags: MessageFlags.Ephemeral, - }, - deferred, - }; + await discordService.updateDeferredReply(interaction.token, { content: "Match not found" }); + + return; } const match = Preconditions.checkExists(matches[0]); - const players = await this.services.haloService.getPlayerXuidsToGametags(match); + const players = await haloService.getPlayerXuidsToGametags(match); const matchEmbed = this.getMatchEmbed(match); const embed = await matchEmbed.getEmbed(match, players); - return { - response: { - embeds: [embed], - flags: MessageFlags.Ephemeral, - }, - deferred, - }; + await discordService.updateDeferredReply(interaction.token, { embeds: [embed] }); } catch (error) { - console.error(error); - - return { - response: { - content: `Failed to fetch (match id: ${matchId}): ${error instanceof Error ? error.message : "unknown"}`, - flags: MessageFlags.Ephemeral, - }, - deferred, - }; + await discordService.updateDeferredReply(interaction.token, { + content: `Failed to fetch (match id: ${matchId}}): ${error instanceof Error ? error.message : "unknown"}`, + }); } } diff --git a/src/server.mts b/src/server.mts index 08ac315..98f75f3 100644 --- a/src/server.mts +++ b/src/server.mts @@ -10,23 +10,35 @@ import { getCommands } from "./commands/commands.mjs"; const router = AutoRouter(); router.get("/", (_request, env: Env) => { - return new Response(`👋 ${env.DISCORD_APP_ID}`); + return new Response( + `👋 G'day from Guilty Spark (env.DISCORD_APP_ID: ${env.DISCORD_APP_ID})... Interested? https://discord.com/oauth2/authorize?client_id=1290269474536034357 🚀`, + ); }); -router.post("/interactions", async (request, env: Env) => { - const services = installServices({ env }); - const { discordService } = services; - const commands = getCommands(services); - discordService.setCommands(commands); +router.post("/interactions", async (request, env: Env, ctx: EventContext) => { + try { + const services = installServices({ env }); + const { discordService } = services; + const commands = getCommands(services); + discordService.setCommands(commands); - const { isValid, interaction } = await discordService.verifyDiscordRequest(request); - if (!isValid || !interaction) { - return new Response("Bad request signature.", { status: 401 }); - } + const { isValid, interaction } = await discordService.verifyDiscordRequest(request); + if (!isValid || !interaction) { + return new Response("Bad request signature.", { status: 401 }); + } + + const { response, jobToComplete } = discordService.handleInteraction(interaction); - const response = await discordService.handleInteraction(interaction); + if (jobToComplete) { + ctx.waitUntil(jobToComplete); + } - return response; + return response; + } catch (error) { + console.error(error); + + return new Response("Internal error", { status: 500 }); + } }); router.all("*", () => new Response("Not Found.", { status: 404 })); diff --git a/src/services/discord/discord.mts b/src/services/discord/discord.mts index b7e6910..2f9dc4e 100644 --- a/src/services/discord/discord.mts +++ b/src/services/discord/discord.mts @@ -18,7 +18,6 @@ import { RESTPostAPIChannelMessageJSONBody, RESTPostAPIChannelMessageResult, RESTPostAPIChannelThreadsResult, - RESTPostAPIInteractionCallbackResult, RESTPostAPIWebhookWithTokenJSONBody, Routes, } from "discord-api-types/v10"; @@ -75,32 +74,48 @@ export class DiscordService { } } - async handleInteraction(interaction: APIInteraction) { + handleInteraction(interaction: APIInteraction): { + response: JsonResponse; + jobToComplete?: Promise | undefined; + } { switch (interaction.type) { case InteractionType.Ping: { - return new JsonResponse({ - type: InteractionResponseType.Pong, - }); + return { + response: new JsonResponse({ + type: InteractionResponseType.Pong, + }), + }; } case InteractionType.ApplicationCommand: { if (!this.commands) { - return new JsonResponse({ error: "No commands found" }, { status: 500 }); + console.error("No commands found"); + return { + response: new JsonResponse({ error: "No commands found" }, { status: 500 }), + }; } const command = this.commands.get(interaction.data.name); if (!command) { - return new JsonResponse({ error: "Command not found" }, { status: 404 }); - } + console.warn("Command not found"); - const { response, deferred } = await command.execute(interaction); - if (deferred) { - await this.updateDeferredReply(interaction.token, response); + return { + response: new JsonResponse({ error: "Command not found" }, { status: 400 }), + }; } - return new JsonResponse(response); + const { response, jobToComplete } = command.execute(interaction); + + return { + response: new JsonResponse(response), + jobToComplete, + }; } default: { - return new JsonResponse({ error: "Unknown Type" }, { status: 400 }); + console.warn("Unknown interaction type"); + + return { + response: new JsonResponse({ error: "Unknown interaction type" }, { status: 400 }), + }; } } } @@ -159,21 +174,13 @@ export class DiscordService { }; } - async acknowledgeInteraction(interaction: APIApplicationCommandInteraction, ephemeral = false) { + getAcknowledgeResponse(ephemeral = false): APIInteractionResponse { const data: { flags?: MessageFlags } = {}; if (ephemeral) { data.flags = MessageFlags.Ephemeral; } - const response: APIInteractionResponse = { type: InteractionResponseType.DeferredChannelMessageWithSource, data }; - - return await this.fetch( - Routes.interactionCallback(interaction.id, interaction.token), - { - method: "POST", - body: JSON.stringify(response), - }, - ); + return { type: InteractionResponseType.DeferredChannelMessageWithSource, data }; } async updateDeferredReply(interactionToken: string, data: RESTPostAPIWebhookWithTokenJSONBody) {