Skip to content

Commit

Permalink
Refactor/fix: rework server to send a response back and run rest of c…
Browse files Browse the repository at this point in the history
…ommand work async (#17)
  • Loading branch information
davidhouweling authored Nov 5, 2024
1 parent 888d94c commit 61eed83
Show file tree
Hide file tree
Showing 5 changed files with 112 additions and 106 deletions.
3 changes: 3 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,7 @@ export default tseslint.config(
},
},
},
{
ignores: [".wrangler/*", "dist/*", "patches/*"],
},
);
15 changes: 4 additions & 11 deletions src/commands/base/base.mts
Original file line number Diff line number Diff line change
@@ -1,22 +1,15 @@
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<void>;
}

export abstract class BaseCommand {
constructor(readonly services: Services) {}

abstract data: Omit<APIApplicationCommand, "id" | "application_id" | "default_member_permissions" | "version">;

abstract execute(interaction: APIApplicationCommandInteraction): Promise<ExecuteResponse>;
abstract execute(interaction: APIApplicationCommandInteraction): ExecuteResponse;
}
111 changes: 51 additions & 60 deletions src/commands/stats/stats.mts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
APIEmbed,
ApplicationCommandOptionType,
ApplicationCommandType,
InteractionResponseType,
MessageFlags,
} from "discord-api-types/v10";
import { GameVariantCategory, MatchStats } from "halo-infinite-api";
Expand Down Expand Up @@ -86,7 +87,7 @@ export class StatsCommand extends BaseCommand {
],
};

async execute(interaction: APIApplicationCommandInteraction): Promise<ExecuteResponse> {
execute(interaction: APIApplicationCommandInteraction): ExecuteResponse {
try {
const subcommand = this.services.discordService.extractSubcommand(interaction, "stats");

Expand All @@ -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");
}
Expand All @@ -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<string, APIApplicationCommandInteractionDataBasicOption["value"]>,
): Promise<ExecuteResponse> {
): 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<void> {
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(
Expand All @@ -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(
Expand All @@ -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<string, APIApplicationCommandInteractionDataBasicOption["value"]>,
): Promise<ExecuteResponse> {
): 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<void> {
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"}`,
});
}
}

Expand Down
36 changes: 24 additions & 12 deletions src/server.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Env, "", unknown>) => {
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 }));

Expand Down
53 changes: 30 additions & 23 deletions src/services/discord/discord.mts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import {
RESTPostAPIChannelMessageJSONBody,
RESTPostAPIChannelMessageResult,
RESTPostAPIChannelThreadsResult,
RESTPostAPIInteractionCallbackResult,
RESTPostAPIWebhookWithTokenJSONBody,
Routes,
} from "discord-api-types/v10";
Expand Down Expand Up @@ -75,32 +74,48 @@ export class DiscordService {
}
}

async handleInteraction(interaction: APIInteraction) {
handleInteraction(interaction: APIInteraction): {
response: JsonResponse;
jobToComplete?: Promise<void> | 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 }),
};
}
}
}
Expand Down Expand Up @@ -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<RESTPostAPIInteractionCallbackResult>(
Routes.interactionCallback(interaction.id, interaction.token),
{
method: "POST",
body: JSON.stringify(response),
},
);
return { type: InteractionResponseType.DeferredChannelMessageWithSource, data };
}

async updateDeferredReply(interactionToken: string, data: RESTPostAPIWebhookWithTokenJSONBody) {
Expand Down

0 comments on commit 61eed83

Please sign in to comment.