im tired.

This commit is contained in:
2026-09-07 17:26:53 +08:00
parent 3aff82422e
commit 016585ddf9
26 changed files with 695 additions and 381 deletions
+2
View File
@@ -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.
+2
View File
@@ -15,6 +15,8 @@ services:
POSTGRES_DB: postgres POSTGRES_DB: postgres
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
ports:
- 5432:5432
volumes: volumes:
postgres_data: postgres_data:
+377 -1
View File
@@ -11,7 +11,8 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "26.2.0", "@types/node": "26.2.0",
"esbuild": "0.28.2" "esbuild": "0.28.2",
"typescript": "7.0.2"
} }
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
@@ -466,6 +467,346 @@
"undici-types": "~8.3.0" "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": { "node_modules/athena-prime": {
"version": "4.4.0", "version": "4.4.0",
"resolved": "https://npm.hep.gg/athena-prime/-/athena-prime-4.4.0.tgz", "resolved": "https://npm.hep.gg/athena-prime/-/athena-prime-4.4.0.tgz",
@@ -564,6 +905,41 @@
"url": "https://github.com/sponsors/porsager" "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": { "node_modules/undici-types": {
"version": "8.3.0", "version": "8.3.0",
"resolved": "https://npm.hep.gg/undici-types/-/undici-types-8.3.0.tgz", "resolved": "https://npm.hep.gg/undici-types/-/undici-types-8.3.0.tgz",
+2 -1
View File
@@ -7,7 +7,8 @@
"devDependencies": { "devDependencies": {
"@types/node": "26.2.0", "@types/node": "26.2.0",
"esbuild": "0.28.2" "esbuild": "0.28.2",
"typescript": "7.0.2"
}, },
"scripts": { "scripts": {
-55
View File
@@ -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<CommandClient> {
users = [
'1363132678022631428', // nxbat
'454653142500507649', // bitdash
];
async #createInformationMessage(context: CommandClient<any, any>, 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<any, any>, 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');
+3 -5
View File
@@ -1,9 +1,7 @@
import init from './admin/init';
import leaderboard from './leaderboard'; import leaderboard from './leaderboard';
import optin from './privacy/optin'; import rank from './rank';
import optout from './privacy/optout'; import settings from './settings';
import profile from './profile';
// ---------- // ----------
export default [init, leaderboard, optin, optout, profile]; export default [leaderboard, rank, settings];
+11 -16
View File
@@ -1,5 +1,5 @@
import { Command, CommandBuilder, CommandClient, CommandInteraction, Constants, SlashCommand } from 'athena-prime'; 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), .setCommandType(Constants.ApplicationCommandType.ChatInput),
) )
class LeaderboardCommand extends Command<CommandClient> { class LeaderboardCommand extends Command<CommandClient> {
cooldown = 15;
async handleCommand(context: CommandClient<any, any>, interaction: CommandInteraction) { async handleCommand(context: CommandClient<any, any>, interaction: CommandInteraction) {
if (!interaction.inGuild()) return;
await interaction.defer(); await interaction.defer();
const _data = await database.getHighestPets(); const data = await database.leaderboard.getLeaderboard(interaction.guild.id);
const description = _data.length const description = data.length
? _data ? data
.map((entry, index) => `**${index + 1}.** <@${entry.userId}> — **${entry.pets.toLocaleString()}** pets!`) .map((entry, index) => `**${index + 1}.** <@${entry.userId}> — **${entry.points}** points!`)
.join('\n') .join('\n')
: 'Nobody has any pets yet!'; : 'Nobody has any points yet!';
await interaction.createMessage({ await interaction.createMessage({
embeds: [ embeds: [
{ {
author: { author: {
name: interaction.user.username, name: interaction.member.username,
icon_url: interaction.user.avatarURL, icon_url: interaction.member.avatarURL,
}, },
color: 0xe77ed1, color: 0xe77ed1,
title: "Pond's Petting Leaderboard", title: `Leaderboard | ${interaction.guild.name}`,
description: description, description: description,
footer: {
text: `Made by nxbat @ Archwing`,
icon_url: 'https://femboytrain.ing/nxbat-archwing',
},
}, },
], ],
}); });
-26
View File
@@ -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<CommandClient> {
cooldown = 5;
async handleCommand(context: CommandClient<any, any>, 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');
-26
View File
@@ -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<CommandClient> {
cooldown = 5;
async handleCommand(context: CommandClient<any, any>, 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');
-60
View File
@@ -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<CommandClient> {
cooldown = 15;
async handleCommand(context: CommandClient<any, any>, 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');
+40
View File
@@ -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<CommandClient> {
async handleCommand(context: CommandClient<any, any>, 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');
+48
View File
@@ -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<CommandClient> {
userPermissions = [Constants.PermissionFlagsBits.ManageGuild];
async handleCommand(context: CommandClient<any, any>, 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');
-6
View File
@@ -1,6 +0,0 @@
export default {
identifiers: {
emojiId: '448912932340891670',
targetId: '1363132678022631428',
},
};
+153
View File
@@ -0,0 +1,153 @@
import postgres, { Sql } from 'postgres';
// ----------
const client: Sql<any> = postgres(process.env.DATABASE_URL);
export async function init(): Promise<void> {
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<Application.Configuration> {
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<void> {
await client`
UPDATE guilds SET config = ${client.json(config)}
WHERE guild_id = ${guildId}
`;
}
export async function deleteConfig(guildId: string): Promise<void> {
await client`
DELETE FROM guilds
WHERE guild_id = ${guildId}
`;
}
//#endregion
//#region Points
export async function getOrCreatePoints(guildId: string, userId: string): Promise<bigint> {
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<void> {
await client`
UPDATE points SET points = ${points}
WHERE guild_id = ${guildId} AND user_id = ${userId}
`;
}
export async function deletePoints(guildId: string, userId: string): Promise<void> {
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<number | null> {
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,
}
}
+14
View File
@@ -0,0 +1,14 @@
import { CommandClient, Event, Guild } from 'athena-prime';
import database from '../../database';
// ----------
class GuildCreateEvent extends Event<CommandClient> {
event: string = 'guildCreate' as const;
async handle(context: CommandClient<any, any>, guild: Guild) {
await database.config.getOrCreateConfig(guild.id);
}
}
export default new GuildCreateEvent('guildCreate');
+15
View File
@@ -0,0 +1,15 @@
import { CommandClient, Event, Guild } from 'athena-prime';
import database from '../../database';
// ----------
class GuildCreateEvent extends Event<CommandClient> {
event: string = 'guildDelete' as const;
// TODO: Implement weekly config cleanup task instead.
async handle(context: CommandClient<any, any>, guild: Guild | { id: string }) {
await database.config.deleteConfig(guild.id);
}
}
export default new GuildCreateEvent('guildDelete');
+4 -1
View File
@@ -1,3 +1,5 @@
import guildCreate from './guild/guildCreate';
import guildDelete from './guild/guildDelete';
import messageReactionAdd from './message/messageReactionAdd'; import messageReactionAdd from './message/messageReactionAdd';
import messageReactionRemove from './message/messageReactionRemove'; import messageReactionRemove from './message/messageReactionRemove';
import ready from './client/ready'; import ready from './client/ready';
@@ -5,7 +7,8 @@ import ready from './client/ready';
// ---------- // ----------
export default [ export default [
// Client. guildCreate,
guildDelete,
messageReactionAdd, messageReactionAdd,
messageReactionRemove, messageReactionRemove,
ready, ready,
+5 -8
View File
@@ -1,6 +1,5 @@
import { CommandClient, Constants, Event, Member, Message } from 'athena-prime'; import { CommandClient, Constants, Event, Member, Message } from 'athena-prime';
import config from '../../config'; import database from '../../database';
import { database } from '../../utils';
// ---------- // ----------
@@ -8,13 +7,11 @@ class MessageReactionAddEvent extends Event<CommandClient> {
event: string = 'messageReactionAdd' as const; event: string = 'messageReactionAdd' as const;
async handle(context: CommandClient<any, any>, message: Message, emoji: Constants.APIEmoji, member: Member) { async handle(context: CommandClient<any, any>, message: Message, emoji: Constants.APIEmoji, member: Member) {
const msg = await context.getMessage(message.channel.id, message.id); const _config = await database.config.getOrCreateConfig(member.guild.id);
if (msg.author.id !== config.identifiers.targetId || emoji.id !== config.identifiers.emojiId) return; if (emoji.id !== _config.emoji_id) return;
const _user = await database.getUser(member.id); const _entry = await database.points.getOrCreatePoints(member.guild.id, member.id);
if (!_user.settings.participating) return; await database.points.updatePoints(member.guild.id, member.id, _entry + 1n);
await database.updateUserStatistics(member.id, { pets: _user.statistics.pets + 1 });
} }
} }
+8 -14
View File
@@ -1,26 +1,20 @@
import { CommandClient, Constants, Event } from 'athena-prime'; import { CommandClient, Constants, Event } from 'athena-prime';
import config from '../../config'; import database from '../../database';
import { database } from '../../utils';
// ---------- // ----------
class MessageReactionRemoveEvent extends Event<CommandClient> { class MessageReactionRemoveEvent extends Event<CommandClient> {
event: string = 'messageReactionRemove' as const; event: string = 'messageReactionRemove' as const;
async handle( async handle(context: CommandClient<any, any>, message: { id: string; channel: { id: string } }, emoji: Constants.APIEmoji, userId: string) {
context: CommandClient<any, any>, const _msg = await context.getMessage(message.channel.id, message.id);
message: { id: string; channel: { id: string } }, if (!_msg.guildID) return;
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;
const _user = await database.getUser(userId); const _config = await database.config.getOrCreateConfig(_msg.guildID);
if (!_user.settings.participating) return; if (emoji.id !== _config.emoji_id) return;
if (_user.statistics.pets >= 0) return; const _entry = await database.points.getOrCreatePoints(_msg.guildID, userId);
await database.updateUserStatistics(userId, { pets: _user.statistics.pets - 1 }); await database.points.updatePoints(_msg.guildID, userId, _entry - 1n);
} }
} }
+3 -8
View File
@@ -2,6 +2,7 @@ import 'dotenv/config';
import { CommandClient, Constants, Guild, Member, Message, NullCollection, User } from 'athena-prime'; import { CommandClient, Constants, Guild, Member, Message, NullCollection, User } from 'athena-prime';
import commands from './commands'; import commands from './commands';
import database from './database';
import events from './events'; import events from './events';
// ---------- // ----------
@@ -16,14 +17,7 @@ const client: CommandClient = new CommandClient({
Constants.GatewayIntentBits.GuildMessageReactions, Constants.GatewayIntentBits.GuildMessageReactions,
], ],
largeBotOptimizations: true, largeBotOptimizations: true
cache: {
users: () => new NullCollection(User),
members: () => new NullCollection(Member),
messages: () => new NullCollection(Message),
guilds: () => new NullCollection(Guild),
},
}, },
}); });
@@ -32,4 +26,5 @@ events.forEach((event) => client.registerEvent(event, true));
// ---------- // ----------
database.init();
client.connect(); client.connect();
+7 -3
View File
@@ -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 { declare global {
namespace Application {
type Configuration = {
emoji_id: string;
};
}
namespace NodeJS { namespace NodeJS {
interface ProcessEnv { interface ProcessEnv {
DATABASE_URL: string; DATABASE_URL: string;
@@ -9,4 +13,4 @@ declare global {
} }
} }
export {}; export { };
-22
View File
@@ -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;
};
-1
View File
@@ -1 +0,0 @@
export * from './database.d';
-126
View File
@@ -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<void> {
await client`
CREATE TABLE IF NOT EXISTS users (
user_id TEXT PRIMARY KEY,
data JSONB NOT NULL
)
`;
}
export async function getUser(userId: string): Promise<User> {
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<void> {
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<void> {
await client`
DELETE FROM users
WHERE user_id = ${userId}
`;
}
export async function updateUserSettings(userId: string, settings: Partial<User['settings']>): Promise<void> {
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<User['statistics']>): Promise<void> {
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<number | null> {
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
-1
View File
@@ -1 +0,0 @@
export * as database from './database';
+1 -1
View File
@@ -8,5 +8,5 @@
"experimentalDecorators": true, "experimentalDecorators": true,
}, },
"include": ["src/**/*.ts"] "include": ["src/**/*.ts"],
} }