Skip to content

Commit 7e8eb1a

Browse files
committed
prototype leaderboard
1 parent fb31390 commit 7e8eb1a

35 files changed

+1783
-43
lines changed

deploy/src/app.ts

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ new DiscordStack(app, "discord", {
1010
// Yes these are not really a "secret" but it's a string that I don't want to store in the repo
1111
domain: discordSecretsJson.domain,
1212
publicKey: discordSecretsJson["discord-public-key"],
13+
leaderboardApi: discordSecretsJson["leaderboard-api"],
1314
env: {
1415
region: process.env.CDK_DEFAULT_REGION,
1516
account: process.env.CDK_DEFAULT_ACCOUNT,

deploy/src/stack/discord.ts

+20-11
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import path from "path";
33
import * as cdk from "aws-cdk-lib";
44
import * as s3 from "aws-cdk-lib/aws-s3";
55
import * as lambda from "aws-cdk-lib/aws-lambda";
6+
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
67
import * as acm from "aws-cdk-lib/aws-certificatemanager";
78
import * as s3deploy from "aws-cdk-lib/aws-s3-deployment";
89
import * as apigateway from "aws-cdk-lib/aws-apigateway";
@@ -11,16 +12,26 @@ import * as route53 from "aws-cdk-lib/aws-route53";
1112

1213
export interface DiscordProps extends cdk.StackProps {
1314
readonly domain: [string, string] | string;
15+
leaderboardApi: string;
1416
readonly publicKey: string;
1517
}
1618

1719
const __dirname = path.dirname(fileURLToPath(import.meta.url));
1820

1921
export class DiscordStack extends cdk.Stack {
2022
constructor(scope: cdk.App, id: string, props: DiscordProps) {
21-
const { domain, publicKey, ...rest } = props;
23+
const { domain, publicKey, leaderboardApi, ...rest } = props;
2224
super(scope, id, rest);
2325

26+
// DynamoDB tables
27+
const minecraftPlayerTable = new dynamodb.Table(this, "MinecraftPlayer", {
28+
partitionKey: {
29+
name: "uuid",
30+
type: dynamodb.AttributeType.STRING,
31+
},
32+
tableClass: dynamodb.TableClass.STANDARD,
33+
});
34+
2435
// Bucket with a single image
2536
const staticAssetBucket = new s3.Bucket(this, "static-assets-bucket-3", {
2637
publicReadAccess: true,
@@ -36,7 +47,7 @@ export class DiscordStack extends cdk.Stack {
3647
destinationBucket: staticAssetBucket,
3748
});
3849

39-
const metadataHandler = new lambda.Function(this, "discordLambda", {
50+
const discordHandler = new lambda.Function(this, "discordLambda", {
4051
runtime: lambda.Runtime.PROVIDED,
4152
code: lambda.Code.fromAsset(
4253
path.join(__dirname, "../../../.layers/discord")
@@ -45,11 +56,6 @@ export class DiscordStack extends cdk.Stack {
4556
timeout: cdk.Duration.seconds(10),
4657
memorySize: 128,
4758
layers: [
48-
// new lambda.LayerVersion(this, "node16Layer-custom", {
49-
// code: lambda.Code.fromAsset(
50-
// path.join(__dirname, "../../../node16Layer/")
51-
// ),
52-
// }),
5359
lambda.LayerVersion.fromLayerVersionArn(
5460
this,
5561
"node16Layer",
@@ -60,9 +66,14 @@ export class DiscordStack extends cdk.Stack {
6066
PUBLIC_KEY: publicKey,
6167
STATIC_IMAGE_URL: `https://${staticAssetBucket.bucketName}.s3.amazonaws.com`,
6268
MINIMUM_LOG_LEVEL: "DEBUG",
69+
TABLE_NAME_MINECRAFT_PLAYER: minecraftPlayerTable.tableName,
70+
CURRENT_LEADERBOARD: "potato",
71+
LEADERBOARD_BASE: leaderboardApi,
6372
},
6473
});
6574

75+
minecraftPlayerTable.grantReadWriteData(discordHandler);
76+
6677
// Domain
6778
const domains = domain instanceof Array ? domain : [domain];
6879
const domainName = domains.join(".");
@@ -85,11 +96,9 @@ export class DiscordStack extends cdk.Stack {
8596
},
8697
});
8798

88-
const metadataIntegration = new apigateway.LambdaIntegration(
89-
metadataHandler
90-
);
99+
const discordIntegration = new apigateway.LambdaIntegration(discordHandler);
91100
const resource = api.root.addResource("discord");
92-
resource.addMethod("POST", metadataIntegration);
101+
resource.addMethod("POST", discordIntegration);
93102

94103
new route53.ARecord(this, "ipv4-record", {
95104
zone: hostedZone,

jest.config.cjs

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
const d_preset = require("@shelf/jest-dynamodb/jest-preset");
2+
13
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
24
module.exports = {
35
preset: 'ts-jest',
6+
...d_preset,
47
testEnvironment: 'node',
58
coverageDirectory: 'coverage',
69
coverageProvider: 'v8',
7-
testMatch: ['**/tests/unit/*.test.ts'],
10+
testMatch: ['<rootDir>/src/**/*.test.ts'],
811
};

package.json

+10-3
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,19 @@
77
"scripts": {
88
"build": "./node_modules/typescript/bin/tsc -b",
99
"discord:cli": "yarn discord:cli:build && node .layers/discord-cli/index.cjs",
10-
"discord:cli:build": "esbuild src/cli.ts --platform=node --target=node --bundle --outfile=.layers/discord-cli/index.cjs",
10+
"discord:cli:build": "esbuild src/cli.ts --platform=node --target=node16.14 --bundle --outfile=.layers/discord-cli/index.cjs",
1111
"discord:build": "esbuild src/lambda/discord.ts --platform=node --target=node16.14 --bundle --outfile=.layers/discord/index.js",
12-
"test": "echo \"Error: no test specified\" && exit 1"
12+
"test": "jest test",
13+
"open:gen": "openapi --input swagger.json --client axios --output ./src/swagger-gen"
1314
},
1415
"devDependencies": {
16+
"@shelf/jest-dynamodb": "^2.2.4",
1517
"@types/aws-lambda": "^8.10.95",
1618
"@types/jest": "^27.4.1",
1719
"esbuild": "^0.14.38",
1820
"eslint": "^8.13.0",
1921
"jest": "^27.5.1",
22+
"openapi-typescript-codegen": "^0.22.0",
2023
"prettier": "^2.6.2",
2124
"ts-jest": "^27.1.4",
2225
"typescript": "^4.6.3"
@@ -26,12 +29,16 @@
2629
"@discordjs/builders": "^0.13.0",
2730
"@discordjs/rest": "^0.4.1",
2831
"@twitter-api-v2/plugin-token-refresher": "^1.0.0",
32+
"@types/uuid": "^8.3.4",
2933
"aws-lambda": "^1.0.7",
34+
"axios": "^0.27.2",
35+
"axios-mock-adapter": "^1.20.0",
3036
"commander": "^9.2.0",
3137
"discord-api-types": "^0.32.0",
3238
"discord.js": "^13.6.0",
3339
"lambda-logger-node": "4.0.0-7",
3440
"tweetnacl": "^1.0.3",
35-
"twitter-api-v2": "^1.12.0"
41+
"twitter-api-v2": "^1.12.0",
42+
"uuid": "^8.3.2"
3643
}
3744
}

secrets/bot-secrets.json

+3-2
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"ENC[AES256_GCM,data:QZeR9CocbpU2,iv:kwyRBjKzFxxkJbLd734+WyEUyw/tuwedw7fL175mO44=,tag:m/FzTN2O3h6FGxBR/dbVzQ==,type:str]",
44
"ENC[AES256_GCM,data:g7WrQRz8z472kwg=,iv:unNGgU49uq4pi6DyZd77MQcm8Nazsjayq/bE3V3M5uU=,tag:+TNzpa66/JDDuPaNesDOqg==,type:str]"
55
],
6+
"leaderboard-api": "ENC[AES256_GCM,data:b7QHfUb3zMuu7UN9cUYei7uUMN4Ev3xKYxxw+YB5c98eBDY6xtT1jOubFJBFFU3N4gx7qh/PsDE9wxJQ,iv:Ba/xIwaLWKIwSDtL3aH7284AKntDKM3GvQarfktizSw=,tag:hJlOLPuL0bhuJpeOrAA/Xw==,type:str]",
67
"discord-public-key": "ENC[AES256_GCM,data:RTeBlHvto9ivMDDs3PcbUV6s/gjbRJ0GA2xrukaLvcMxpnssCIM9amSAu8Qev1XCR+9vmfuIBXgoqZSnd77qvw==,iv:+E6vmxhM85gWchJ1iVLkdRWDxV9uyN72XEDBv6bkr/U=,tag:pV9QLGPC0Mk4SQjhPZUUcw==,type:str]",
78
"discord-app-id": "ENC[AES256_GCM,data:iCRoI4381bOz6Cs41EoeccED,iv:BvjeQxjfX73k3m4PrpVxfEt6Qi6fq/v2MRKMK9A6vOg=,tag:Uw5WvHOTcnmY1uHvI3Qv3w==,type:str]",
89
"discord-bot-token": "ENC[AES256_GCM,data:u0RmuHk00wigFEIj5Biu9DBRGxatEtDaHYmxKEETf+U/mIBm5hKbjqrikbBSA2I0uj9DFu3Lt3DaiSs=,iv:QTnTmV/xHGejUtDi8y7lfeYzcDujbrv7VV/M4ufA6h0=,tag:8zkfBgFN/SF1zGc9rScmGg==,type:str]",
@@ -15,8 +16,8 @@
1516
"azure_kv": null,
1617
"hc_vault": null,
1718
"age": null,
18-
"lastmodified": "2022-04-26T01:24:39Z",
19-
"mac": "ENC[AES256_GCM,data:Lhv5zz1xEEi8Bh9/+qxsvifDQ9dlCxt3bwiWmDnffs9thx+004KKUvUZxiVZtF5TGR3KZVDBRQ7YBednxMsSfrYXbcdGEhUxS7VU3Descy6af61mU19WODDGVq1036dW9C9GEZ4HdkPfqYp5aF2yxamscFAPw94ma5seY5xhp/w=,iv:l0wraHQx8Qx90VnTXZ0j9zqzl+KgS2Eq2HAMLoSz5ws=,tag:3uXlAc+oBYp0i7dfwYdDQg==,type:str]",
19+
"lastmodified": "2022-05-08T14:34:46Z",
20+
"mac": "ENC[AES256_GCM,data:SrdR08e+GXDBKIzGKl4M4pJUAAX0DccErJqigXcxwSU7PLHOUAfh0siyJXjQgB88QU0BmUSD+R/+5n7840SXyUJA0ZKlfP6w/8Bx0dtXKgO9Ec45JbPQraOqoHbcUmIg0kmc1B8GdzrSD9Pfb+MpoYty8orRewtG3e918oGB8Ag=,iv:bqhSso7XkbcwjcTd9P8AJZfJqC++roU2+Mfw96GYYAk=,tag:FZcTYNvZBYmfgypiLu0/Kw==,type:str]",
2021
"pgp": [
2122
{
2223
"created_at": "2022-04-22T15:41:22Z",

src/cli.ts

+60-22
Original file line numberDiff line numberDiff line change
@@ -14,29 +14,67 @@ commandCommand
1414
.description("Creates slash commands for a guild")
1515
.option("--client_id <client_id>", "Client ID")
1616
.option("--guild_id <guild_id>", "Guild ID")
17-
.option("--token <token>", "Token")
18-
.action(async ({ client_id: clientId, guild_id: guildId, token }) => {
19-
console.log(`Creating commands for guild ${guildId}`);
20-
const commands = [
21-
new SlashCommandBuilder()
22-
.setName("ping")
23-
.setDescription("Replies with pong!"),
24-
new SlashCommandBuilder()
25-
.setName("token")
26-
.setDescription("Replies with information about the token")
27-
.addIntegerOption((option) =>
28-
option.setName("id").setDescription("Token ID").setRequired(true)
29-
),
30-
].map((command) => command.toJSON());
17+
.option("--discord-token <token>", "Token")
18+
.option("--exclude <exclude>", "Exclude commands")
19+
.action(
20+
async ({
21+
client_id: clientId,
22+
guild_id: guildId,
23+
discordToken: token,
24+
exclude,
25+
}) => {
26+
console.log(`Creating commands for guild ${guildId}`);
27+
exclude = exclude || "";
28+
exclude = exclude.split(",").map((x: string) => x.trim());
29+
const commands = (
30+
[
31+
[
32+
"ping",
33+
new SlashCommandBuilder()
34+
.setName("ping")
35+
.setDescription("Replies with pong!"),
36+
],
37+
[
38+
"token",
39+
new SlashCommandBuilder()
40+
.setName("token")
41+
.setDescription("Replies with information about the token")
42+
.addIntegerOption((option) =>
43+
option
44+
.setName("id")
45+
.setDescription("Token ID")
46+
.setRequired(true)
47+
),
48+
],
49+
[
50+
"mclb",
51+
new SlashCommandBuilder()
52+
.setName("mclb")
53+
.setDescription("Minecraft leaderboard")
54+
.addStringOption((option) =>
55+
option
56+
.setName("name")
57+
.setDescription(
58+
"The leaderboard to show, otherwise the current/default"
59+
)
60+
),
61+
],
62+
] as [string, SlashCommandBuilder][]
63+
)
64+
.filter(([name, _]) => !exclude.includes(name))
65+
.map(([_, command]) => command.toJSON());
3166

32-
const rest = new REST({ version: "9" }).setToken(token);
67+
const rest = new REST({ version: "9" }).setToken(token);
3368

34-
rest
35-
.put(Routes.applicationGuildCommands(clientId, guildId), {
36-
body: commands,
37-
})
38-
.then(() => console.log("Successfully registered application commands."))
39-
.catch(console.error);
40-
});
69+
rest
70+
.put(Routes.applicationGuildCommands(clientId, guildId), {
71+
body: commands,
72+
})
73+
.then(() =>
74+
console.log("Successfully registered application commands.")
75+
)
76+
.catch(console.error);
77+
}
78+
);
4179

4280
program.parse(process.argv);

src/commands/leaderboard.ts

+94
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { InteractionResponseType } from "discord-api-types/v10";
2+
import { MessageEmbed } from "discord.js";
3+
import { register } from "../interactions/command.js";
4+
import { createLogger } from "../utils/logging";
5+
import { LeaderboardService, Experiences } from "../swagger-gen/index.js";
6+
import { playerByUuid } from "../model/CachedMinecraftPlayer.js";
7+
8+
const logger = createLogger();
9+
logger.setKey("command", "leaderboard");
10+
11+
const KNOWN_LEADERBOARDS = [Experiences.POTATO];
12+
const ONLY_PERIOD = "alltime";
13+
14+
register({
15+
handler: async (interaction) => {
16+
if (interaction.data.name !== "mclb") {
17+
return false;
18+
}
19+
logger.info("Received leaderboard command");
20+
const leaderboardOption = interaction.data.options.find(
21+
({ name }) => name === "name"
22+
);
23+
const leaderboardName: Experiences =
24+
leaderboardOption?.value ?? process.env.CURRENT_LEADERBOARD;
25+
if (!KNOWN_LEADERBOARDS.includes(leaderboardName)) {
26+
logger.info("Unknown leaderboard");
27+
return {
28+
statusCode: 400,
29+
body: JSON.stringify({ error: "unknown leaderboard" }),
30+
};
31+
}
32+
logger.info("Fetching leaderboard");
33+
const lb = await LeaderboardService.getLeaderboard(
34+
leaderboardName,
35+
ONLY_PERIOD,
36+
10
37+
);
38+
// collect all player_ids
39+
if (typeof lb.items === "undefined") {
40+
logger.info("No leaderboard items");
41+
const message = new MessageEmbed();
42+
message.setTitle("Leaderboard is empty");
43+
return {
44+
statusCode: 200,
45+
type: InteractionResponseType.ChannelMessageWithSource,
46+
data: {
47+
embeds: [message],
48+
},
49+
};
50+
}
51+
logger.info(`Found ${lb.items.length} leaderboard items`);
52+
const items = lb.items.filter((item) => item.Player_ID);
53+
const playerIds = items.map((item) => item.Player_ID as string);
54+
55+
if (playerIds.length === 0) {
56+
const message = new MessageEmbed();
57+
message.setTitle("Leaderboard is empty");
58+
return {
59+
statusCode: 200,
60+
type: InteractionResponseType.ChannelMessageWithSource,
61+
data: {
62+
embeds: [message],
63+
},
64+
};
65+
}
66+
67+
logger.info(`Fetching ${playerIds.length} player names`);
68+
const players = await Promise.all(playerIds.map(playerByUuid));
69+
70+
logger.info(`Found ${players.length} players`);
71+
const message = new MessageEmbed()
72+
.setTitle(`Example leaderboard`)
73+
.setDescription(`1st place: ${players[0].name}`)
74+
.setImage(`https://crafatar.com/renders/body/${playerIds[0]}`)
75+
.setFooter({ text: "Thank you to crafatar.com for providing avatars." });
76+
77+
for (let i = 1; i < players.length; i++) {
78+
message.addField(
79+
`${`${i + 1}`.padStart(3)}: ${players[i].name}`,
80+
`${items[i]?.Score?.[0]}` || "0"
81+
);
82+
}
83+
84+
return {
85+
statusCode: 200,
86+
body: JSON.stringify({
87+
type: InteractionResponseType.ChannelMessageWithSource,
88+
data: {
89+
embeds: [message],
90+
},
91+
}),
92+
};
93+
},
94+
});

src/error/NotFound.ts

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export class NotFoundError extends Error {
2+
public constructor(message: string) {
3+
super(message);
4+
this.name = "NotFoundError";
5+
}
6+
}

src/interactions/command.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,10 @@ export function register(handler: ICommandHandler): void {
1919
export async function handle(
2020
interaction: InferredApplicationCommandType
2121
): Promise<APIGatewayProxyResult> {
22-
logger.info("Received interaction", { interaction });
2322
for (const handler of handlers) {
2423
const result = await handler.handler(interaction);
2524
if (result) {
26-
logger.debug("Handled interaction", { interaction });
25+
logger.info("Handled interaction");
2726
return result;
2827
}
2928
}

src/lambda/discord.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
22
import { sign } from "tweetnacl";
3+
import { OpenAPI } from "../swagger-gen/index.js";
34
import { createLogger } from "../utils/logging.js";
45
import { InferredApplicationCommandType } from "../types.js";
56
import { handle as pingHandler } from "../interactions/ping.js";
67
import { handle as commandHandler } from "../interactions/command.js";
78
import { APIInteraction, InteractionType } from "discord-api-types/v10";
89
import "../commands/ping.js";
910
import "../commands/token.js";
11+
import "../commands/leaderboard.js";
12+
13+
OpenAPI.BASE = process.env.LEADERBOARD_BASE || OpenAPI.BASE;
14+
1015
/**
1116
*
1217
* Event doc: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format
@@ -87,7 +92,7 @@ export const handler = async (
8792
}),
8893
};
8994
}
90-
} catch (e) {
95+
} catch (e: any) {
9196
logger.error(e);
9297
return {
9398
statusCode: 500,

0 commit comments

Comments
 (0)