diff --git a/README.md b/README.md new file mode 100644 index 0000000..46e7fe5 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# Pet the Pond +A points-based reaction bot for Discord. If anyone wants to take this project off my hands, please take it. I don't want it anymore. \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 710bb99..dac38e9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,8 @@ services: POSTGRES_DB: postgres volumes: - postgres_data:/var/lib/postgresql/data + ports: + - 5432:5432 volumes: postgres_data: diff --git a/package-lock.json b/package-lock.json index 2f33d32..93c808f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ }, "devDependencies": { "@types/node": "26.2.0", - "esbuild": "0.28.2" + "esbuild": "0.28.2", + "typescript": "7.0.2" } }, "node_modules/@esbuild/aix-ppc64": { @@ -466,6 +467,346 @@ "undici-types": "~8.3.0" } }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://npm.hep.gg/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://npm.hep.gg/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://npm.hep.gg/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://npm.hep.gg/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://npm.hep.gg/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://npm.hep.gg/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://npm.hep.gg/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://npm.hep.gg/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://npm.hep.gg/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://npm.hep.gg/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://npm.hep.gg/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://npm.hep.gg/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://npm.hep.gg/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://npm.hep.gg/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://npm.hep.gg/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://npm.hep.gg/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://npm.hep.gg/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://npm.hep.gg/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://npm.hep.gg/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://npm.hep.gg/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/athena-prime": { "version": "4.4.0", "resolved": "https://npm.hep.gg/athena-prime/-/athena-prime-4.4.0.tgz", @@ -564,6 +905,41 @@ "url": "https://github.com/sponsors/porsager" } }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://npm.hep.gg/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, "node_modules/undici-types": { "version": "8.3.0", "resolved": "https://npm.hep.gg/undici-types/-/undici-types-8.3.0.tgz", diff --git a/package.json b/package.json index 416874f..d475a51 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "devDependencies": { "@types/node": "26.2.0", - "esbuild": "0.28.2" + "esbuild": "0.28.2", + "typescript": "7.0.2" }, "scripts": { diff --git a/src/commands/admin/init.ts b/src/commands/admin/init.ts deleted file mode 100644 index 25a1b60..0000000 --- a/src/commands/admin/init.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { Command, CommandBuilder, CommandClient, CommandInteraction, Constants, SlashCommand } from 'athena-prime'; -import config from '../../config'; -import { database } from '../../utils'; - -// ---------- - -@SlashCommand( - new CommandBuilder('init', 'Runs pre-start operations, then starts the bot for operation.') - .setIntegrationTypes(Constants.ApplicationIntegrationType.GuildInstall) - .setContexts(Constants.InteractionContextType.Guild) - .setCommandType(Constants.ApplicationCommandType.ChatInput) - .setMemberPermission(Constants.PermissionFlagsBits.Administrator), -) -class InitCommand extends Command { - users = [ - '1363132678022631428', // nxbat - '454653142500507649', // bitdash - ]; - - async #createInformationMessage(context: CommandClient, interaction: CommandInteraction) { - await database.init(); - - await context.createMessage(interaction.channel.id, { - embed: { - color: 0xe77ed1, - - title: 'Pet the Pond!', - description: ` - There's a Pond that needs petting, and a leaderboard to show off your level of appreciation. - - **How does it work?** - On any message sent by <@${config.identifiers.targetId}>, react with the <:pet:448912932340891670> emoji to receive a point. - - **What do you get?** - You get appreciation and proof you appreciate Pond the most. - - **What if I don't want to be involved?** - You can opt in & out using the \`/optin\` & \`/optout\` commands. - `, - - footer: { - text: `Made by nxbat @ Archwing`, - icon_url: 'https://femboytrain.ing/nxbat-archwing', - }, - }, - }); - } - - async handleCommand(context: CommandClient, interaction: CommandInteraction) { - for (const handler of [this.#createInformationMessage]) await handler(context, interaction); - await interaction.createMessage({ content: 'Done.', flags: Constants.MessageFlags.Ephemeral }); - } -} - -export default new InitCommand('init'); diff --git a/src/commands/index.ts b/src/commands/index.ts index da29c40..2b992b3 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,9 +1,7 @@ -import init from './admin/init'; import leaderboard from './leaderboard'; -import optin from './privacy/optin'; -import optout from './privacy/optout'; -import profile from './profile'; +import rank from './rank'; +import settings from './settings'; // ---------- -export default [init, leaderboard, optin, optout, profile]; +export default [leaderboard, rank, settings]; diff --git a/src/commands/leaderboard.ts b/src/commands/leaderboard.ts index 471b55f..934ef80 100644 --- a/src/commands/leaderboard.ts +++ b/src/commands/leaderboard.ts @@ -1,5 +1,5 @@ import { Command, CommandBuilder, CommandClient, CommandInteraction, Constants, SlashCommand } from 'athena-prime'; -import { database } from '../utils'; +import database from '../database'; // ---------- @@ -10,35 +10,30 @@ import { database } from '../utils'; .setCommandType(Constants.ApplicationCommandType.ChatInput), ) class LeaderboardCommand extends Command { - cooldown = 15; - async handleCommand(context: CommandClient, interaction: CommandInteraction) { + if (!interaction.inGuild()) return; + await interaction.defer(); - const _data = await database.getHighestPets(); - const description = _data.length - ? _data - .map((entry, index) => `**${index + 1}.** <@${entry.userId}> — **${entry.pets.toLocaleString()}** pets!`) + const data = await database.leaderboard.getLeaderboard(interaction.guild.id); + const description = data.length + ? data + .map((entry, index) => `**${index + 1}.** <@${entry.userId}> — **${entry.points}** points!`) .join('\n') - : 'Nobody has any pets yet!'; + : 'Nobody has any points yet!'; await interaction.createMessage({ embeds: [ { author: { - name: interaction.user.username, - icon_url: interaction.user.avatarURL, + name: interaction.member.username, + icon_url: interaction.member.avatarURL, }, color: 0xe77ed1, - title: "Pond's Petting Leaderboard", + title: `Leaderboard | ${interaction.guild.name}`, description: description, - - footer: { - text: `Made by nxbat @ Archwing`, - icon_url: 'https://femboytrain.ing/nxbat-archwing', - }, }, ], }); diff --git a/src/commands/privacy/optin.ts b/src/commands/privacy/optin.ts deleted file mode 100644 index 13c0e03..0000000 --- a/src/commands/privacy/optin.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Command, CommandBuilder, CommandClient, CommandInteraction, Constants, SlashCommand } from 'athena-prime'; -import { database } from '../../utils'; - -// ---------- - -@SlashCommand( - new CommandBuilder('optin', 'Stop counting your pet interactions.') - .setIntegrationTypes(Constants.ApplicationIntegrationType.GuildInstall) - .setContexts(Constants.InteractionContextType.Guild) - .setCommandType(Constants.ApplicationCommandType.ChatInput), -) -class OptInCommand extends Command { - cooldown = 5; - - async handleCommand(context: CommandClient, interaction: CommandInteraction) { - await interaction.defer(true); - - await database.updateUserSettings(interaction.user.id, { participating: true }); - - await interaction.createMessage({ - content: "Successfully opted in. You'll receive points for your pets!", - }); - } -} - -export default new OptInCommand('optin'); diff --git a/src/commands/privacy/optout.ts b/src/commands/privacy/optout.ts deleted file mode 100644 index bceb2cf..0000000 --- a/src/commands/privacy/optout.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Command, CommandBuilder, CommandClient, CommandInteraction, Constants, SlashCommand } from 'athena-prime'; -import { database } from '../../utils'; - -// ---------- - -@SlashCommand( - new CommandBuilder('optout', 'Stop counting your pet interactions.') - .setIntegrationTypes(Constants.ApplicationIntegrationType.GuildInstall) - .setContexts(Constants.InteractionContextType.Guild) - .setCommandType(Constants.ApplicationCommandType.ChatInput), -) -class OptOutCommand extends Command { - cooldown = 5; - - async handleCommand(context: CommandClient, interaction: CommandInteraction) { - await interaction.defer(true); - - await database.updateUserSettings(interaction.user.id, { participating: false }); - - await interaction.createMessage({ - content: "Successfully opted out. You won't receive any points for petting :<", - }); - } -} - -export default new OptOutCommand('optout'); diff --git a/src/commands/profile.ts b/src/commands/profile.ts deleted file mode 100644 index 97e5f5a..0000000 --- a/src/commands/profile.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Command, CommandBuilder, CommandClient, CommandInteraction, Constants, SlashCommand } from 'athena-prime'; -import { database } from '../utils'; - -// ---------- - -@SlashCommand( - new CommandBuilder('profile', 'View your profile.') - .setIntegrationTypes(Constants.ApplicationIntegrationType.GuildInstall) - .setContexts(Constants.InteractionContextType.Guild) - .setCommandType(Constants.ApplicationCommandType.ChatInput), -) -class ProfileCommand extends Command { - cooldown = 15; - - async handleCommand(context: CommandClient, interaction: CommandInteraction) { - await interaction.defer(); - - const _user = await database.getUser(interaction.user.id); - const position = await database.getPetPosition(interaction.user.id); - - await interaction.createMessage({ - embeds: [ - { - author: { - name: interaction.user.username, - icon_url: interaction.user.avatarURL, - }, - - color: 0xe77ed1, - - title: `Your Profile | Pet the Pond`, - fields: [ - { - name: 'Pets', - value: `${_user.statistics.pets}`, - inline: true, - }, - { - name: 'Rank', - value: position ? `#${position}` : 'N/A', - inline: true, - }, - { - name: 'Participating', - value: _user.settings.participating ? 'Yes' : 'No', - inline: false, - }, - ], - - footer: { - text: `Made by nxbat @ Archwing`, - icon_url: 'https://femboytrain.ing/nxbat-archwing', - }, - }, - ], - }); - } -} - -export default new ProfileCommand('profile'); diff --git a/src/commands/rank.ts b/src/commands/rank.ts new file mode 100644 index 0000000..8b16b5f --- /dev/null +++ b/src/commands/rank.ts @@ -0,0 +1,40 @@ +import { Command, CommandBuilder, CommandClient, CommandInteraction, Constants, SlashCommand } from "athena-prime"; +import database from "../database"; + +// ---------- + +@SlashCommand( + new CommandBuilder('rank', 'See your rank information.') + .setIntegrationTypes(Constants.ApplicationIntegrationType.GuildInstall) + .setContexts(Constants.InteractionContextType.Guild) + .setCommandType(Constants.ApplicationCommandType.ChatInput) +) +class RankCommand extends Command { + async handleCommand(context: CommandClient, interaction: CommandInteraction) { + if (!interaction.inGuild()) return; + + await interaction.defer(); + + const _points = await database.points.getOrCreatePoints(interaction.guild.id, interaction.member.id); + const _rank = await database.leaderboard.getLeaderboardPosition(interaction.guild.id, interaction.member.id); + + await interaction.createMessage({ + embeds: [{ + author: { + name: interaction.member.username, + icon_url: interaction.member.avatarURL + }, + + color: 0xe77ed1, + title: `Rank | ${interaction.member.username}`, + + fields: [ + { name: 'Points', value: `${_points}`, inline: true }, + { name: 'Rank', value: `${_rank ?? 'N/A'}`, inline: true } + ], + }] + }); + } +} + +export default new RankCommand('rank'); \ No newline at end of file diff --git a/src/commands/settings.ts b/src/commands/settings.ts new file mode 100644 index 0000000..f8575d5 --- /dev/null +++ b/src/commands/settings.ts @@ -0,0 +1,48 @@ +import { Command, CommandBuilder, CommandClient, CommandInteraction, Constants, SlashCommand } from "athena-prime"; +import database from "../database"; + +// ---------- + +@SlashCommand( + new CommandBuilder('settings', 'Configure guild-level settings.') + .setIntegrationTypes(Constants.ApplicationIntegrationType.GuildInstall) + .setContexts(Constants.InteractionContextType.Guild) + .setCommandType(Constants.ApplicationCommandType.ChatInput) + .setMemberPermission(Constants.PermissionFlagsBits.ManageGuild) + .addSubcommand('emoji', 'Set the emoji used for petting.', (builder) => { + builder.addStringOption({ + name: 'value', + description: 'The custom emoji ID to use.', + required: true, + min_length: 17, + max_length: 20, + }); + }) +) +class SettingsCommand extends Command { + userPermissions = [Constants.PermissionFlagsBits.ManageGuild]; + + async handleCommand(context: CommandClient, interaction: CommandInteraction) { + if (!interaction.inGuild() || !interaction.subcommand) return; + await interaction.defer(true); + + switch (interaction.subcommand) { + case 'emoji': + const config = await database.config.getOrCreateConfig(interaction.guild.id); + const emojiId = interaction.getRequiredString('value'); + + if (!/^\d{17,20}$/.test(emojiId)) { + return await interaction.createMessage({ content: 'Please provide a valid custom emoji ID.' }); + } + + await database.config.updateConfig(interaction.guild.id, { + ...config, + emoji_id: emojiId, + }); + + return await interaction.createMessage({ content: `The reaction emoji ID has been set to ${emojiId}.` }); + } + } +} + +export default new SettingsCommand('settings'); \ No newline at end of file diff --git a/src/config.ts b/src/config.ts deleted file mode 100644 index ebe26f2..0000000 --- a/src/config.ts +++ /dev/null @@ -1,6 +0,0 @@ -export default { - identifiers: { - emojiId: '448912932340891670', - targetId: '1363132678022631428', - }, -}; diff --git a/src/database.ts b/src/database.ts new file mode 100644 index 0000000..3aade6d --- /dev/null +++ b/src/database.ts @@ -0,0 +1,153 @@ +import postgres, { Sql } from 'postgres'; + +// ---------- + +const client: Sql = postgres(process.env.DATABASE_URL); + +export async function init(): Promise { + await client` + CREATE TABLE IF NOT EXISTS guilds ( + guild_id TEXT PRIMARY KEY, + config JSONB NOT NULL default '{}'::jsonb + );` + + await client` + CREATE TABLE IF NOT EXISTS points ( + guild_id TEXT NOT NULL, + user_id TEXT NOT NULL, + points BIGINT NOT NULL DEFAULT 0, + + PRIMARY KEY (guild_id, user_id), + + CONSTRAINT fk_points_guild + FOREIGN KEY (guild_id) + REFERENCES guilds (guild_id) + ON DELETE CASCADE + ); + `; +} + +//#region Configuration + +export async function getOrCreateConfig(guildId: string): Promise { + const [inserted] = await client<{ config: Application.Configuration }[]>` + INSERT INTO guilds (guild_id) + VALUES (${guildId}) + ON CONFLICT (guild_id) DO NOTHING + RETURNING config + `; + + if (inserted) return inserted.config; + + const [row] = await client<{ config: Application.Configuration }[]>` + SELECT config FROM guilds + WHERE guild_id = ${guildId} + `; + + return row.config; +} + +export async function updateConfig(guildId: string, config: Application.Configuration): Promise { + await client` + UPDATE guilds SET config = ${client.json(config)} + WHERE guild_id = ${guildId} + `; +} + +export async function deleteConfig(guildId: string): Promise { + await client` + DELETE FROM guilds + WHERE guild_id = ${guildId} + `; +} + +//#endregion + +//#region Points + +export async function getOrCreatePoints(guildId: string, userId: string): Promise { + const [inserted] = await client<{ points: bigint }[]>` + INSERT INTO points (guild_id, user_id) + VALUES (${guildId}, ${userId}) + ON CONFLICT (guild_id, user_id) DO NOTHING + RETURNING points + `; + + if (inserted) return inserted.points; + + const [row] = await client<{ points: bigint }[]>` + SELECT points FROM points + WHERE guild_id = ${guildId} AND user_id = ${userId} + `; + + return row.points; +} + +export async function updatePoints(guildId: string, userId: string, points: bigint): Promise { + await client` + UPDATE points SET points = ${points} + WHERE guild_id = ${guildId} AND user_id = ${userId} + `; +} + +export async function deletePoints(guildId: string, userId: string): Promise { + await client` + DELETE FROM points + WHERE guild_id = ${guildId} AND user_id = ${userId} + `; +} + +//#endregion + +//#region Leaderboard + +export async function getLeaderboard(guildId: string, limit: number = 10): Promise<{ userId: string; points: bigint }[]> { + return client<{ userId: string; points: bigint }[]>` + SELECT user_id AS "userId", points FROM points + WHERE guild_id = ${guildId} AND points > 0 + ORDER BY points DESC, user_id ASC + LIMIT ${limit} + `; +} + +export async function getLeaderboardPosition(guildId: string, userId: string): Promise { + const [row] = await client<{ rank: number | null }[]>` + WITH target AS ( + SELECT points FROM points + WHERE guild_id = ${guildId} AND user_id = ${userId} + ) + SELECT CASE WHEN COUNT(target.*) = 0 THEN NULL ELSE COUNT(candidate.*)::int + 1 END AS rank + FROM target + LEFT JOIN points AS candidate + ON candidate.guild_id = ${guildId} + AND ( + candidate.points > target.points + OR (candidate.points = target.points AND candidate.user_id < ${userId}) + ) + `; + + return row.rank; +} + +//#endregion + +export default { + init, + + config: { + getOrCreateConfig, + updateConfig, + deleteConfig + }, + + points: { + getOrCreatePoints, + updatePoints, + deletePoints + }, + + leaderboard: { + getLeaderboard, + getLeaderboardPosition, + } +} \ No newline at end of file diff --git a/src/events/guild/guildCreate.ts b/src/events/guild/guildCreate.ts new file mode 100644 index 0000000..4712072 --- /dev/null +++ b/src/events/guild/guildCreate.ts @@ -0,0 +1,14 @@ +import { CommandClient, Event, Guild } from 'athena-prime'; +import database from '../../database'; + +// ---------- + +class GuildCreateEvent extends Event { + event: string = 'guildCreate' as const; + + async handle(context: CommandClient, guild: Guild) { + await database.config.getOrCreateConfig(guild.id); + } +} + +export default new GuildCreateEvent('guildCreate'); diff --git a/src/events/guild/guildDelete.ts b/src/events/guild/guildDelete.ts new file mode 100644 index 0000000..577f284 --- /dev/null +++ b/src/events/guild/guildDelete.ts @@ -0,0 +1,15 @@ +import { CommandClient, Event, Guild } from 'athena-prime'; +import database from '../../database'; + +// ---------- + +class GuildCreateEvent extends Event { + event: string = 'guildDelete' as const; + + // TODO: Implement weekly config cleanup task instead. + async handle(context: CommandClient, guild: Guild | { id: string }) { + await database.config.deleteConfig(guild.id); + } +} + +export default new GuildCreateEvent('guildDelete'); diff --git a/src/events/index.ts b/src/events/index.ts index a9f5f8f..c7175cb 100644 --- a/src/events/index.ts +++ b/src/events/index.ts @@ -1,3 +1,5 @@ +import guildCreate from './guild/guildCreate'; +import guildDelete from './guild/guildDelete'; import messageReactionAdd from './message/messageReactionAdd'; import messageReactionRemove from './message/messageReactionRemove'; import ready from './client/ready'; @@ -5,7 +7,8 @@ import ready from './client/ready'; // ---------- export default [ - // Client. + guildCreate, + guildDelete, messageReactionAdd, messageReactionRemove, ready, diff --git a/src/events/message/messageReactionAdd.ts b/src/events/message/messageReactionAdd.ts index e9af7e7..61aa74d 100644 --- a/src/events/message/messageReactionAdd.ts +++ b/src/events/message/messageReactionAdd.ts @@ -1,6 +1,5 @@ import { CommandClient, Constants, Event, Member, Message } from 'athena-prime'; -import config from '../../config'; -import { database } from '../../utils'; +import database from '../../database'; // ---------- @@ -8,13 +7,11 @@ class MessageReactionAddEvent extends Event { event: string = 'messageReactionAdd' as const; async handle(context: CommandClient, message: Message, emoji: Constants.APIEmoji, member: Member) { - const msg = await context.getMessage(message.channel.id, message.id); - if (msg.author.id !== config.identifiers.targetId || emoji.id !== config.identifiers.emojiId) return; + const _config = await database.config.getOrCreateConfig(member.guild.id); + if (emoji.id !== _config.emoji_id) return; - const _user = await database.getUser(member.id); - if (!_user.settings.participating) return; - - await database.updateUserStatistics(member.id, { pets: _user.statistics.pets + 1 }); + const _entry = await database.points.getOrCreatePoints(member.guild.id, member.id); + await database.points.updatePoints(member.guild.id, member.id, _entry + 1n); } } diff --git a/src/events/message/messageReactionRemove.ts b/src/events/message/messageReactionRemove.ts index 2ab6fff..72f46eb 100644 --- a/src/events/message/messageReactionRemove.ts +++ b/src/events/message/messageReactionRemove.ts @@ -1,26 +1,20 @@ import { CommandClient, Constants, Event } from 'athena-prime'; -import config from '../../config'; -import { database } from '../../utils'; +import database from '../../database'; // ---------- class MessageReactionRemoveEvent extends Event { event: string = 'messageReactionRemove' as const; - async handle( - context: CommandClient, - message: { id: string; channel: { id: string } }, - emoji: Constants.APIEmoji, - userId: string, - ) { - const msg = await context.getMessage(message.channel.id, message.id); - if (msg.author.id !== config.identifiers.targetId || emoji.id !== config.identifiers.emojiId) return; + async handle(context: CommandClient, message: { id: string; channel: { id: string } }, emoji: Constants.APIEmoji, userId: string) { + const _msg = await context.getMessage(message.channel.id, message.id); + if (!_msg.guildID) return; - const _user = await database.getUser(userId); - if (!_user.settings.participating) return; + const _config = await database.config.getOrCreateConfig(_msg.guildID); + if (emoji.id !== _config.emoji_id) return; - if (_user.statistics.pets >= 0) return; - await database.updateUserStatistics(userId, { pets: _user.statistics.pets - 1 }); + const _entry = await database.points.getOrCreatePoints(_msg.guildID, userId); + await database.points.updatePoints(_msg.guildID, userId, _entry - 1n); } } diff --git a/src/index.ts b/src/index.ts index 05260dd..8400816 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import 'dotenv/config'; import { CommandClient, Constants, Guild, Member, Message, NullCollection, User } from 'athena-prime'; import commands from './commands'; +import database from './database'; import events from './events'; // ---------- @@ -16,14 +17,7 @@ const client: CommandClient = new CommandClient({ Constants.GatewayIntentBits.GuildMessageReactions, ], - largeBotOptimizations: true, - - cache: { - users: () => new NullCollection(User), - members: () => new NullCollection(Member), - messages: () => new NullCollection(Message), - guilds: () => new NullCollection(Guild), - }, + largeBotOptimizations: true }, }); @@ -32,4 +26,5 @@ events.forEach((event) => client.registerEvent(event, true)); // ---------- +database.init(); client.connect(); diff --git a/src/types/env.d.ts b/src/types.d.ts similarity index 50% rename from src/types/env.d.ts rename to src/types.d.ts index 7f5c80f..532d5cb 100644 --- a/src/types/env.d.ts +++ b/src/types.d.ts @@ -1,6 +1,10 @@ -// Used for typing purposes only. -// There's an expectation that the person setting up the bot isn't a complete idiot. declare global { + namespace Application { + type Configuration = { + emoji_id: string; + }; + } + namespace NodeJS { interface ProcessEnv { DATABASE_URL: string; @@ -9,4 +13,4 @@ declare global { } } -export {}; +export { }; diff --git a/src/types/database.d.ts b/src/types/database.d.ts deleted file mode 100644 index 95952bd..0000000 --- a/src/types/database.d.ts +++ /dev/null @@ -1,22 +0,0 @@ -export type UserSettings = { - participating: boolean; -}; - -export type UserStatistics = { - pets: number; -}; - -/** - * Database User. - */ -export type User = { - /** - * User settings specific to each user. - */ - settings: UserSettings; - - /** - * User statistics. - */ - statistics: UserStatistics; -}; diff --git a/src/types/index.d.ts b/src/types/index.d.ts deleted file mode 100644 index a51157c..0000000 --- a/src/types/index.d.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './database.d'; diff --git a/src/utils/database.ts b/src/utils/database.ts deleted file mode 100644 index e9aea6e..0000000 --- a/src/utils/database.ts +++ /dev/null @@ -1,126 +0,0 @@ -import postgres from 'postgres'; -import { User } from '../types'; - -const client = postgres(process.env.DATABASE_URL); - -const getDefaultUser = (): User => ({ - settings: { - participating: true, - }, - - statistics: { - pets: 0, - }, -}); - -export async function init(): Promise { - await client` - CREATE TABLE IF NOT EXISTS users ( - user_id TEXT PRIMARY KEY, - data JSONB NOT NULL - ) - `; -} - -export async function getUser(userId: string): Promise { - let [user] = await client<{ user_id: string; data: User }[]>` - SELECT user_id, data FROM users - WHERE user_id = ${userId} - `; - - if (user) return user.data; - - [user] = await client<{ user_id: string; data: User }[]>` - INSERT INTO users (user_id, data) - VALUES (${userId}, ${client.json(getDefaultUser())}) - ON CONFLICT (user_id) - DO NOTHING - RETURNING user_id, data - `; - - return user?.data ?? getDefaultUser(); -} - -export async function setUser(userId: string, data: User): Promise { - await client` - INSERT INTO users (user_id, data) - VALUES (${userId}, ${client.json(data)}) - ON CONFLICT (user_id) - DO UPDATE SET data = EXCLUDED.data - `; -} - -export async function deleteUser(userId: string): Promise { - await client` - DELETE FROM users - WHERE user_id = ${userId} - `; -} - -export async function updateUserSettings(userId: string, settings: Partial): Promise { - await client` - UPDATE users - SET data = jsonb_set( - data, - '{settings}', - data->'settings' || ${client.json(settings)} - ) - WHERE user_id = ${userId} - `; -} - -export async function updateUserStatistics(userId: string, statistics: Partial): Promise { - await client` - UPDATE users - SET data = jsonb_set( - data, - '{statistics}', - data->'statistics' || ${client.json(statistics)} - ) - WHERE user_id = ${userId} - `; -} - -//#region Leaderboard - -export async function getHighestPets(limit = 10): Promise<{ userId: string; pets: number }[]> { - const users = await client<{ user_id: string; pets: number }[]>` - SELECT user_id, (data->'statistics'->>'pets')::integer AS pets - FROM users - WHERE - COALESCE((data->'flags'->>'blacklisted')::boolean, false) = false - AND COALESCE((data->'settings'->>'participating')::boolean, true) = true - ORDER BY pets DESC, user_id ASC - LIMIT ${limit} - `; - - return users.map((user) => ({ - userId: user.user_id, - pets: user.pets, - })); -} - -export async function getPetPosition(userId: string): Promise { - const result = await client<{ position: number }[]>` - SELECT position - FROM ( - SELECT - user_id, - RANK() OVER (ORDER BY pets DESC, user_id ASC) AS position - FROM ( - SELECT - user_id, - (data->'statistics'->>'pets')::integer AS pets - FROM users - WHERE - COALESCE((data->'flags'->>'blacklisted')::boolean, false) = false - AND COALESCE((data->'settings'->>'participating')::boolean, true) = true - ) ranked - ) ranked_users - WHERE user_id = ${userId} - `; - - return result[0]?.position ?? null; -} - -//#endregion diff --git a/src/utils/index.ts b/src/utils/index.ts deleted file mode 100644 index dd502a8..0000000 --- a/src/utils/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * as database from './database'; diff --git a/tsconfig.json b/tsconfig.json index c5c379a..d125b7b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,5 +8,5 @@ "experimentalDecorators": true, }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts"], }