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

Set up ttl queue #5

Merged
merged 11 commits into from
Oct 1, 2023
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"deno.enable": true,
"deno.unstable": true,
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[typescript]": {
Expand Down
9 changes: 9 additions & 0 deletions app/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ export const SHORTER_DESTINATION = "destination";
export const SHORTER_DESTINATION_DESCRIPTION =
"The destination of the shortlink.";

export const SHORTER_TTL = "ttl";
export const SHORTER_TTL_DESCRIPTION =
"The time-to-live of the shortlink (e.g. `1d`, `1w`, `1m`, `1y`, etc.).";

/**
* APP_SHORTER is the top-level command for the Shorter Application Command.
*/
Expand All @@ -31,5 +35,10 @@ export const APP_SHORTER: discord.RESTPostAPIApplicationCommandsJSONBody = {
description: SHORTER_DESTINATION_DESCRIPTION,
required: true,
},
{
type: discord.ApplicationCommandOptionType.String,
name: SHORTER_TTL,
description: SHORTER_TTL_DESCRIPTION,
},
],
};
271 changes: 193 additions & 78 deletions deno.lock

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions deps.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * as dotenv from "https://deno.land/std@0.201.0/dotenv/mod.ts";
export * as discord from "https://deno.land/x/[email protected].56/v10.ts";
export * as dotenv from "https://deno.land/std@0.203.0/dotenv/mod.ts";
export * as discord from "https://deno.land/x/[email protected].58/v10.ts";
export * from "https://deno.land/x/[email protected]/github/mod.ts";
export type { GitHubAPIClientOptions } from "https://deno.land/x/[email protected]/github/api/mod.ts";
export { Duration } from "https://deno.land/x/[email protected]/mod.ts";
export { default as nacl } from "https://esm.sh/[email protected]";
187 changes: 113 additions & 74 deletions main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,17 @@
// deno task ngrok
//

import { discord } from "./deps.ts";
import { discord, Duration } from "./deps.ts";
import { DiscordAPIClient, verify } from "./discord/mod.ts";
import { APP_SHORTER, SHORTER_ALIAS, SHORTER_DESTINATION } from "./app/mod.ts";
import {
APP_SHORTER,
SHORTER_ALIAS,
SHORTER_DESTINATION,
SHORTER_TTL,
} from "./app/mod.ts";
import type { ShorterOptions } from "./shorter.ts";
import { shorter } from "./shorter.ts";
import { addTTLMessage, listenToTTLChannel } from "./queue.ts";
import * as env from "./env.ts";

const api = new DiscordAPIClient();
Expand All @@ -20,9 +26,16 @@ if (import.meta.main) {
/**
* main is the entrypoint for the Shorter application command.
*/
export function main() {
export async function main() {
// Set up queue listener.
const kv = await Deno.openKv();
kv.listenQueue(listenToTTLChannel);

// Start the server.
Deno.serve({ port: env.PORT, onListen }, handle);
Deno.serve(
{ port: env.PORT, onListen },
makeHandler(kv),
);
}

async function onListen() {
Expand All @@ -41,88 +54,114 @@ async function onListen() {
}

/**
* handle is the HTTP handler for the Shorter application command.
* makeHandler makes the HTTP handler for the Shorter application command.
*/
export async function handle(request: Request): Promise<Response> {
// Redirect to the invite URL on GET /invite.
const url = new URL(request.url);
if (request.method === "GET" && url.pathname === "/invite") {
return Response.redirect(INVITE_URL);
}

// Verify the request.
const { error, body } = await verify(request, env.DISCORD_PUBLIC_KEY);
if (error !== null) {
return error;
}

// Parse the incoming request as JSON.
const interaction = await JSON.parse(body) as discord.APIInteraction;
switch (interaction.type) {
case discord.InteractionType.Ping: {
return Response.json({ type: discord.InteractionResponseType.Pong });
export function makeHandler(kv: Deno.Kv) {
/**
* handle is the HTTP handler for the Shorter application command.
*/
return async function handle(request: Request): Promise<Response> {
// Redirect to the invite URL on GET /invite.
const url = new URL(request.url);
if (request.method === "GET" && url.pathname === "/invite") {
return Response.redirect(INVITE_URL);
}

case discord.InteractionType.ApplicationCommand: {
if (
!discord.Utils.isChatInputApplicationCommandInteraction(interaction)
) {
return new Response("Invalid request", { status: 400 });
}
// Verify the request.
const { error, body } = await verify(request, env.DISCORD_PUBLIC_KEY);
if (error !== null) {
return error;
}

if (!interaction.member?.user) {
return new Response("Invalid request", { status: 400 });
// Parse the incoming request as JSON.
const interaction = await JSON.parse(body) as discord.APIInteraction;
switch (interaction.type) {
case discord.InteractionType.Ping: {
return Response.json({ type: discord.InteractionResponseType.Pong });
}

if (
!interaction.member.roles.some((role) => env.DISCORD_ROLE_ID === role)
) {
return new Response("Invalid request", { status: 400 });
}

// Make the Shorter options.
const options = makeShorterOptions(
interaction.member,
interaction.data,
);

// Invoke the Shorter operation.
shorter(options)
.then((result) =>
api.editOriginalInteractionResponse({
botID: env.DISCORD_CLIENT_ID,
botToken: env.DISCORD_TOKEN,
interactionToken: interaction.token,
content:
`Created commit [${result.message}](https://acmcsuf.com/code/commit/${result.sha})!`,
})
)
.catch((error) => {
if (error instanceof Error) {
api.editOriginalInteractionResponse({
case discord.InteractionType.ApplicationCommand: {
if (
!discord.Utils.isChatInputApplicationCommandInteraction(interaction)
) {
return new Response("Invalid request", { status: 400 });
}

if (!interaction.member?.user) {
return new Response("Invalid request", { status: 400 });
}

if (
!interaction.member.roles.some((role) => env.DISCORD_ROLE_ID === role)
) {
return new Response("Invalid request", { status: 400 });
}

// Make the Shorter options.
const options = makeShorterOptions(
interaction.member,
interaction.data,
);

// Invoke the Shorter operation.
shorter(options)
.then(async (result) => {
// Send the success message.
await api.editOriginalInteractionResponse({
botID: env.DISCORD_CLIENT_ID,
botToken: env.DISCORD_TOKEN,
interactionToken: interaction.token,
content: `Error: ${error.message}`,
content:
`Created commit [${result.message}](https://acmcsuf.com/code/commit/${result.sha})!`,
EthanThatOneKid marked this conversation as resolved.
Show resolved Hide resolved
});
}

console.error(error);
});

// Acknowledge the interaction.
return Response.json(
{
type:
discord.InteractionResponseType.DeferredChannelMessageWithSource,
} satisfies discord.APIInteractionResponseDeferredChannelMessageWithSource,
);
}

default: {
return new Response("Invalid request", { status: 400 });
// Get the TTL option.
const ttlOption = interaction.data.options
?.find((option) => option.name === SHORTER_TTL);
if (ttlOption) {
if (
ttlOption.type !== discord.ApplicationCommandOptionType.String
) {
throw new Error("Invalid TTL");
}

// Parse the TTL in milliseconds.
const ttlDuration = Duration.fromString(ttlOption.value).raw;

// Enqueue the delete operation.
await addTTLMessage(kv, {
alias: options.data.alias,
actor: options.actor,
}, ttlDuration);
}
})
.catch((error) => {
if (error instanceof Error) {
api.editOriginalInteractionResponse({
botID: env.DISCORD_CLIENT_ID,
botToken: env.DISCORD_TOKEN,
interactionToken: interaction.token,
content: `Error: ${error.message}`,
});
}

console.error(error);
});

// Acknowledge the interaction.
return Response.json(
{
type:
discord.InteractionResponseType.DeferredChannelMessageWithSource,
} satisfies discord.APIInteractionResponseDeferredChannelMessageWithSource,
);
}

default: {
return new Response("Invalid request", { status: 400 });
}
}
}
};
}

/**
Expand Down
50 changes: 50 additions & 0 deletions queue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { ShorterOptions } from "./shorter.ts";
import { shorter } from "./shorter.ts";
import * as env from "./env.ts";

/**
* TTLMessage is a message received from the TTL channel.
*/
export interface TTLMessage {
channel: "ttl";
data: {
alias: string;
actor: ShorterOptions["actor"];
};
}

/**
* listenToTTLChannel listens to the TTL channel.
*/
export function listenToTTLChannel(m: unknown) {
if ((m as TTLMessage).channel === "ttl") {
const data = (m as TTLMessage).data;

// Remove the shortlink from persisted storage.
shorter({
githubPAT: env.GITHUB_TOKEN,
actor: data.actor,
// Omitting the destination will remove the alias.
data: { alias: data.alias },
})
.catch((error) => {
if (error instanceof Error) {
console.error(error);
}
});
}
}

/**
* addTTLMessage adds a TTL message to the TTL channel.
*/
export async function addTTLMessage(
kv: Deno.Kv,
data: TTLMessage["data"],
delay: number,
) {
return await kv.enqueue(
{ channel: "ttl", data },
{ delay },
);
}
20 changes: 9 additions & 11 deletions shorter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ export async function shorter(options: ShorterOptions): Promise<ShorterResult> {
);
}

data[options.data.alias] = options.data.destination;
if (options.data.destination === undefined) {
delete data[options.data.alias];
} else {
data[options.data.alias] = options.data.destination;
}

return JSON.stringify(data, null, 2) + "\n";
})
)
Expand Down Expand Up @@ -78,8 +83,10 @@ export interface ShorterOptions {

/**
* destination is the destination location.
*
* If destination is not provided, the alias will be removed.
*/
destination: string;
destination?: string;
};
}

Expand All @@ -98,15 +105,6 @@ export interface ShorterResult {
message: string;
}

/**
* ShorterError is an error that occurs during shortening.
*/
export class ShorterError extends Error {
public constructor(message: string) {
super(message);
}
}

function formatCommitMessage(options: ShorterOptions): string {
return `update \`/${options.data.alias}\` shortlink`;
}
Expand Down
Loading