diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 76a284f..0e3a02e 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -37,7 +37,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@v2
+ uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -48,7 +48,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
- uses: github/codeql-action/autobuild@v2
+ uses: github/codeql-action/autobuild@v3
# âšī¸ Command-line programs to run using the OS shell.
# đ https://git.io/JvXDl
@@ -62,4 +62,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v2
+ uses: github/codeql-action/analyze@v3
diff --git a/src/assets/committieeLeaderboardBackground.svg b/src/assets/committieeLeaderboardBackground.svg
new file mode 100644
index 0000000..713896f
--- /dev/null
+++ b/src/assets/committieeLeaderboardBackground.svg
@@ -0,0 +1,21 @@
+
diff --git a/src/commands/Info/committiee.ts b/src/commands/Info/committiee.ts
new file mode 100644
index 0000000..db52b50
--- /dev/null
+++ b/src/commands/Info/committiee.ts
@@ -0,0 +1,224 @@
+import { ApplyOptions } from '@sapphire/decorators';
+import { UserError, type Args, type Command } from '@sapphire/framework';
+import { SubcommandOptions } from '@sapphire/plugin-subcommands';
+import { ActionRowBuilder, BaseMessageOptions, ButtonBuilder, ButtonStyle, EmbedBuilder, Guild, time, User, type Message } from 'discord.js';
+import { SteveSubcommand } from '@lib/extensions/SteveSubcommand';
+import { sendLoadingMessage } from '@lib/utils';
+import { createCanvas, loadImage } from 'canvas';
+import { readFileSync } from 'fs';
+
+@ApplyOptions({
+ description: 'Find out and log Committee meetups',
+ preconditions: ['CommitteeOnly'],
+ detailedDescription: {
+ usage: 'meetup \nleaderboard',
+ examples: [
+ 'meetup Apr-28-2023 <@696783853267976202>',
+ 'leaderboard'
+ ]
+ },
+ subcommands: [
+ {
+ name: 'meetup',
+ messageRun: 'messageNewMeetup',
+ chatInputRun: 'chatInputNewMeetup'
+ },
+ {
+ name: 'leaderboard',
+ messageRun: 'messageLeaderboard',
+ chatInputRun: 'chatInputLeaderboard'
+ }
+ ]
+})
+export class UserCommand extends SteveSubcommand {
+
+ public override registerApplicationCommands(registry: Command.Registry) {
+ registry.registerChatInputCommand(builder => {
+ builder
+ .setName(this.name)
+ .setDescription(this.description)
+ .addSubcommand(subcommand => {
+ subcommand
+ .setName('meetup')
+ .setDescription('Create a new meetup')
+ .addStringOption(option => option
+ .setName('date')
+ .setDescription('The date of the meetup (Mon-DD-YYYY)')
+ .setRequired(true)
+ );
+
+ for (let i = 1; i <= 10; i++) {
+ subcommand.addUserOption(option => option.setName(`attendee${i}`).setDescription(`Attendee ${i}`).setRequired(i === 1));
+ }
+ return subcommand;
+ })
+ .addSubcommand(subcommand => subcommand
+ .setName('leaderboard')
+ .setDescription('See the current leaderboard')
+ );
+ }, { guildIds: ['700378785605877820'] });
+ }
+
+ private async newMeetup(id: string, date: Date, author: User, attendees: User[]): Promise {
+ const confirmedMembers = [author.id];
+ const nonUniquePossibleMembers = [author.id, ...attendees.map(user => user.id)];
+ const possibleMembers = [...new Set(nonUniquePossibleMembers)];
+
+ await this.container.db.meetups.insertOne({
+ id,
+ confirmedMembers,
+ possibleMembers,
+ date
+ });
+
+ return {
+ content: `<@${possibleMembers.join('>, <@')}>, did you all meet up on ${time(date, 'D')}?`,
+ components: [
+ new ActionRowBuilder().addComponents(
+ new ButtonBuilder()
+ .setLabel('I was there!')
+ .setCustomId(`meetup|${id}`)
+ .setStyle(ButtonStyle.Success)
+ )
+ ]
+ };
+ }
+
+ public async messageNewMeetup(msg: Message, args: Args) {
+ const resp = await sendLoadingMessage(msg);
+ const date = await args.pick('date');
+ const attendees = [...await args.repeat('user')];
+ resp.edit(await this.newMeetup(resp.id, date, msg.author, attendees));
+ }
+
+ public async chatInputNewMeetup(interaction: Command.ChatInputCommandInteraction) {
+ await interaction.deferReply();
+ const date = new Date(interaction.options.getString('date', true));
+ const attendees: User[] = [];
+
+ for (let i = 1; i <= 10; i++) {
+ const maybeAttendee = interaction.options.getUser(`attendee${i}`);
+ if (maybeAttendee) {
+ attendees.push(maybeAttendee);
+ }
+ }
+
+ interaction.editReply(await this.newMeetup(interaction.id, date, interaction.user, attendees));
+ }
+
+ public async messageLeaderboard(msg: Message) {
+ const resp = await sendLoadingMessage(msg);
+ if (!msg.inGuild()) {
+ throw new UserError({ message: 'This command must be run in a server.', identifier: 'NoGuildLeaderboardRun' });
+ }
+ resp.edit(await this.createLeaderboard(msg.guild));
+ }
+
+ public async chatInputLeaderboard(interaction: Command.ChatInputCommandInteraction) {
+ await interaction.deferReply();
+ if (!interaction.guild) {
+ throw new UserError({ message: 'This command must be run in a server.', identifier: 'NoGuildLeaderboardRun' });
+ }
+ interaction.editReply(await this.createLeaderboard(interaction.guild));
+ }
+
+ private async createLeaderboard(committiee: Guild): Promise {
+ const stats = await this.container.db.meetups.aggregate([
+ {
+ $unwind: '$confirmedMembers'
+ },
+ {
+ $group: {
+ _id: '$confirmedMembers',
+ count: { $sum: 1 },
+ latestDate: { $max: '$date' }
+ }
+ },
+ {
+ $sort: { count: -1 }
+ }
+ ]).toArray();
+
+ const members = await Promise.all(stats.map(async (stat) => {
+ const member = await committiee.members.fetch(stat._id);
+ return {
+ name: member.displayName,
+ pfp: await loadImage(member.displayAvatarURL({ extension: 'png' })),
+ count: stat.count,
+ latestDate: stat.latestDate
+ };
+ }));
+
+ const background = await loadImage(readFileSync(`${__dirname}../../../../src/assets/committieeLeaderboardBackground.svg`));
+ const canvas = createCanvas(background.width, background.height);
+ const ctx = canvas.getContext('2d');
+ ctx.font = '35px Sans';
+ ctx.fillStyle = '#FFFFFF';
+
+ ctx.drawImage(background, 0, 0);
+
+ const podiumPositions = [
+ { x: 330, y: 20 },
+ { x: 152, y: 80 },
+ { x: 508, y: 158 }
+ ] as const;
+
+ for (let i = 0; i < members.length; i++) {
+ if (i < 3) {
+ ctx.resetTransform();
+ ctx.save();
+ ctx.translate(podiumPositions[i].x, podiumPositions[i].y);
+ ctx.beginPath();
+ ctx.arc(45, 45, 45, 0, Math.PI * 2);
+ ctx.clip();
+ ctx.drawImage(members[i].pfp, 0, 0, 90, 90);
+ ctx.restore();
+ }
+
+ ctx.resetTransform();
+ ctx.translate(30, 375 + (i * 45));
+ ctx.save();
+ ctx.beginPath();
+ ctx.arc(35 / 2, 35 / 2, 35 / 2, 0, Math.PI * 2);
+ ctx.clip();
+ ctx.drawImage(members[i].pfp, 0, 0, 35, 35);
+ ctx.restore();
+ ctx.fillText(`${members[i].count}|${members[i].name}`, 45, 25);
+ }
+
+ return {
+ content: '',
+ embeds: [
+ new EmbedBuilder()
+ .setImage('attachment://background.png')
+ ],
+ files: [
+ { attachment: canvas.toBuffer('image/png'), name: 'background.png' }
+ ]
+ };
+ }
+
+}
+
+type LeaderboardStat = {
+ _id: string;
+ count: number;
+ latestDate: Date;
+};
+
+// Useful for leaderboard
+// db.meetups.aggregate([
+// {
+// $unwind: "$confirmedMembers"
+// },
+// {
+// $group: {
+// _id: "$confirmedMembers",
+// count: { $sum: 1 },
+// latestDate: { $max: "$date" }
+// }
+// },
+// {
+// $sort: { count: -1 }
+// }
+// ]);
diff --git a/src/interaction-handlers/committieeMeetup.ts b/src/interaction-handlers/committieeMeetup.ts
new file mode 100644
index 0000000..c2986d3
--- /dev/null
+++ b/src/interaction-handlers/committieeMeetup.ts
@@ -0,0 +1,51 @@
+import { ApplyOptions } from '@sapphire/decorators';
+import { InteractionHandlerOptions, InteractionHandlerTypes, InteractionHandler } from '@sapphire/framework';
+import { ActionRowBuilder, ButtonBuilder, ButtonInteraction, ButtonStyle } from 'discord.js';
+
+@ApplyOptions({
+ interactionHandlerType: InteractionHandlerTypes.Button
+})
+export class CommittieeMeetup extends InteractionHandler {
+
+ public async parse(interaction: ButtonInteraction) {
+ if (!interaction.customId.startsWith('meetup')) {
+ return this.none();
+ }
+ await interaction.deferReply({ ephemeral: true });
+ return this.some();
+ }
+
+
+ public async run(interaction: ButtonInteraction) {
+ const meetup = await this.container.db.meetups.findOne({ id: interaction.customId.split('|')[1] });
+
+ if (!meetup) {
+ return interaction.editReply('Something went wrong and I couldn\'t find this meetup :(');
+ }
+
+ if (!meetup.possibleMembers.includes(interaction.user.id)) {
+ return interaction.editReply('Looks like you aren\'t a part of this meetup');
+ }
+
+ if (meetup.confirmedMembers.includes(interaction.user.id)) {
+ return interaction.editReply('You\'re already confirmed');
+ }
+
+ meetup.confirmedMembers.push(interaction.user.id);
+ await this.container.db.meetups.updateOne({ id: meetup.id }, { $set: { confirmedMembers: meetup.confirmedMembers } });
+
+ if (meetup.possibleMembers.length >= meetup.confirmedMembers.length) {
+ await interaction.message.edit({ components: [
+ new ActionRowBuilder().setComponents(new ButtonBuilder()
+ .setLabel('Everyone confirmed!')
+ .setCustomId(`meetup|${meetup.id}`)
+ .setStyle(ButtonStyle.Primary)
+ .setDisabled(true)
+ )
+ ] });
+ }
+
+ return interaction.editReply('Thanks for confirming!');
+ }
+
+}
diff --git a/src/lib/mongo.ts b/src/lib/mongo.ts
index eea2f67..e84dc1c 100644
--- a/src/lib/mongo.ts
+++ b/src/lib/mongo.ts
@@ -10,7 +10,8 @@ import type {
ChannelRename,
QuickRoll,
RPCharter,
- RollImportCharacter
+ RollImportCharacter,
+ Meetup
} from '@lib/types/database';
export interface SteveCollections {
@@ -24,6 +25,7 @@ export interface SteveCollections {
quickRolls: Collection;
rpCharacters: Collection;
rollImportCharacters: Collection;
+ meetups: Collection
}
export async function startMongo() {
@@ -46,7 +48,8 @@ export async function startMongo() {
channelRename: database.collection('channelRename'),
quickRolls: database.collection('quickRolls'),
rpCharacters: database.collection('rpCharacters'),
- rollImportCharacters: database.collection('rollImportCharacters')
+ rollImportCharacters: database.collection('rollImportCharacters'),
+ meetups: database.collection('meetups')
};
container.mongo = mongo;
diff --git a/src/lib/types/database/index.d.ts b/src/lib/types/database/index.d.ts
index bc5cc8b..0c2f094 100644
--- a/src/lib/types/database/index.d.ts
+++ b/src/lib/types/database/index.d.ts
@@ -10,3 +10,4 @@ export * from './quickRoll';
export * from './idHint';
export * from './rpCharacter';
export * from './rollImportCharacter';
+export * from './meetup';
diff --git a/src/lib/types/database/meetup.d.ts b/src/lib/types/database/meetup.d.ts
new file mode 100644
index 0000000..d35f7c9
--- /dev/null
+++ b/src/lib/types/database/meetup.d.ts
@@ -0,0 +1,6 @@
+export type Meetup = {
+ id: string;
+ possibleMembers: string[];
+ confirmedMembers: string[]
+ date: Date;
+};