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
-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 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];
+11 -16
View File
@@ -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<CommandClient> {
cooldown = 15;
async handleCommand(context: CommandClient<any, any>, 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',
},
},
],
});
-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 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,
+5 -8
View File
@@ -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<CommandClient> {
event: string = 'messageReactionAdd' as const;
async handle(context: CommandClient<any, any>, 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);
}
}
+8 -14
View File
@@ -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<CommandClient> {
event: string = 'messageReactionRemove' as const;
async handle(
context: CommandClient<any, any>,
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<any, any>, 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);
}
}
+3 -8
View File
@@ -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();
+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 {
namespace Application {
type Configuration = {
emoji_id: string;
};
}
namespace NodeJS {
interface ProcessEnv {
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';