Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor/fix: rework server to send a response back and run rest of command work async #17

Merged
merged 4 commits into from
Nov 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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