mirror of
https://github.com/ahmadk953/poixpixel-discord-bot.git
synced 2025-05-10 02:33:06 +00:00
pr: merge pull request #304 from ahmadk953/fun-features
This commit is contained in:
commit
072c34d778
82 changed files with 9585 additions and 442 deletions
5
.commitlintrc
Normal file
5
.commitlintrc
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"extends": [
|
||||||
|
"@commitlint/config-conventional"
|
||||||
|
]
|
||||||
|
}
|
39
.github/workflows/commitlint.yml
vendored
Normal file
39
.github/workflows/commitlint.yml
vendored
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
name: Commitlint
|
||||||
|
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
commitlint:
|
||||||
|
name: Run commitlint scanning
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node-version: [23.x]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Configure Corepack
|
||||||
|
run: corepack enable
|
||||||
|
|
||||||
|
- name: Use Node.js ${{ matrix.node-version }}
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node-version }}
|
||||||
|
cache: yarn
|
||||||
|
|
||||||
|
- name: Install commitlint
|
||||||
|
run: |
|
||||||
|
yarn add conventional-changelog-conventionalcommits
|
||||||
|
yarn add commitlint@latest
|
||||||
|
|
||||||
|
- name: Validate current commit (last commit) with commitlint
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
run: npx commitlint --last --verbose
|
||||||
|
|
||||||
|
- name: Validate PR commits with commitlint
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose
|
2
.github/workflows/npm-build-and-compile.yml
vendored
2
.github/workflows/npm-build-and-compile.yml
vendored
|
@ -12,7 +12,7 @@ jobs:
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node-version: [21.x]
|
node-version: [23.x]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
1
.husky/commit-msg
Normal file
1
.husky/commit-msg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
yarn dlx commitlint --edit \
|
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
|
@ -0,0 +1 @@
|
||||||
|
yarn lint-staged
|
1
.husky/pre-push
Normal file
1
.husky/pre-push
Normal file
|
@ -0,0 +1 @@
|
||||||
|
yarn compile
|
12
.lintstagedrc.mjs
Normal file
12
.lintstagedrc.mjs
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import path from 'path';
|
||||||
|
import process from 'process';
|
||||||
|
|
||||||
|
const buildEslintCommand = (filenames) =>
|
||||||
|
`eslint ${filenames.map((f) => path.relative(process.cwd(), f)).join(' ')}`;
|
||||||
|
|
||||||
|
const prettierCommand = 'prettier --write';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
'*.{js,mjs,ts,mts}': [prettierCommand, buildEslintCommand],
|
||||||
|
'*.json': [prettierCommand],
|
||||||
|
};
|
26
.vscode/launch.json
vendored
26
.vscode/launch.json
vendored
|
@ -1,26 +0,0 @@
|
||||||
{
|
|
||||||
"version": "0.1.0",
|
|
||||||
"configurations": [
|
|
||||||
{
|
|
||||||
"name": "Build and Run",
|
|
||||||
"type": "node",
|
|
||||||
"request": "launch",
|
|
||||||
"program": "${workspaceFolder}/target/_.cjs",
|
|
||||||
"preLaunchTask": "build",
|
|
||||||
"skipFiles": ["<node_internals>/**"],
|
|
||||||
"outFiles": ["${workspaceFolder}/target/**/*.cjs"]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"tasks": [
|
|
||||||
{
|
|
||||||
"label": "build",
|
|
||||||
"type": "shell",
|
|
||||||
"command": "node",
|
|
||||||
"args": ["${workspaceFolder}/build/compile.js"],
|
|
||||||
"group": {
|
|
||||||
"kind": "build",
|
|
||||||
"isDefault": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
14
.vscode/tasks.json
vendored
14
.vscode/tasks.json
vendored
|
@ -1,14 +0,0 @@
|
||||||
{
|
|
||||||
"tasks": [
|
|
||||||
{
|
|
||||||
"label": "build",
|
|
||||||
"type": "shell",
|
|
||||||
"command": "node",
|
|
||||||
"args": ["${workspaceFolder}/build/compile.js"],
|
|
||||||
"group": {
|
|
||||||
"kind": "build",
|
|
||||||
"isDefault": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
17
README.md
17
README.md
|
@ -1,7 +1,10 @@
|
||||||
# Poixpixel's Discord Bot
|
# Poixpixel's Discord Bot
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> This Discord bot is not production ready and everything is subject to change
|
> This Discord bot is not production ready and is still in a testing state.
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> Want to see the bot in action? [Join our Discord server](https://discord.gg/KRTGjxx7gY).
|
||||||
|
|
||||||
## Documentation & Setup Instructions
|
## Documentation & Setup Instructions
|
||||||
|
|
||||||
|
@ -14,8 +17,18 @@ All documentation and setup instructions can be found at [https://docs.poixpixel
|
||||||
|
|
||||||
Install Dependencies: ``yarn install``
|
Install Dependencies: ``yarn install``
|
||||||
|
|
||||||
|
Lint: ``yarn lint``
|
||||||
|
|
||||||
|
Check Formatting: ``yarn format``
|
||||||
|
|
||||||
|
Fix Formatting: ``yarn format:fix``
|
||||||
|
|
||||||
Compile: ``yarn compile``
|
Compile: ``yarn compile``
|
||||||
|
|
||||||
Start: ``yarn target``
|
Start: ``yarn target``
|
||||||
|
|
||||||
Build & Start: ``yarn start``
|
Build & Start (dev): ``yarn start:dev``
|
||||||
|
|
||||||
|
Build & Start (prod): ``yarn start:prod``
|
||||||
|
|
||||||
|
Restart (works only when the bot is started with ``yarn start:prod``): ``yarn restart``
|
||||||
|
|
BIN
assets/fonts/Manrope-Bold.ttf
Normal file
BIN
assets/fonts/Manrope-Bold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Manrope-Regular.ttf
Normal file
BIN
assets/fonts/Manrope-Regular.ttf
Normal file
Binary file not shown.
BIN
assets/images/trophy.png
Normal file
BIN
assets/images/trophy.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.4 KiB |
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
@ -1,16 +1,62 @@
|
||||||
{
|
{
|
||||||
"token": "DISCORD_BOT_API_KEY",
|
"token": "DISCORD_BOT_TOKEN",
|
||||||
"clientId": "DISCORD_BOT_ID",
|
"clientId": "DISCORD_BOT_ID",
|
||||||
"guildId": "DISCORD_SERVER_ID",
|
"guildId": "DISCORD_SERVER_ID",
|
||||||
|
"database": {
|
||||||
"dbConnectionString": "POSTGRESQL_CONNECTION_STRING",
|
"dbConnectionString": "POSTGRESQL_CONNECTION_STRING",
|
||||||
|
"maxRetryAttempts": "MAX_RETRY_ATTEMPTS",
|
||||||
|
"retryDelay": "RETRY_DELAY_IN_MS"
|
||||||
|
},
|
||||||
|
"redis": {
|
||||||
"redisConnectionString": "REDIS_CONNECTION_STRING",
|
"redisConnectionString": "REDIS_CONNECTION_STRING",
|
||||||
|
"retryAttempts": "RETRY_ATTEMPTS",
|
||||||
|
"initialRetryDelay": "INITIAL_RETRY_DELAY_IN_MS"
|
||||||
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
"welcome": "WELCOME_CHANNEL_ID",
|
"welcome": "WELCOME_CHANNEL_ID",
|
||||||
"logs": "LOG_CHAANNEL_ID"
|
"logs": "LOG_CHANNEL_ID",
|
||||||
|
"counting": "COUNTING_CHANNEL_ID",
|
||||||
|
"factOfTheDay": "FACT_OF_THE_DAY_CHANNEL_ID",
|
||||||
|
"factApproval": "FACT_APPROVAL_CHANNEL_ID",
|
||||||
|
"advancements": "ADVANCEMENTS_CHANNEL_ID"
|
||||||
},
|
},
|
||||||
"roles": {
|
"roles": {
|
||||||
"joinRoles": [
|
"joinRoles": [
|
||||||
"JOIN_ROLE_IDS"
|
"JOIN_ROLE_IDS"
|
||||||
]
|
],
|
||||||
|
"levelRoles": [
|
||||||
|
{
|
||||||
|
"level": "LEVEL_NUMBER",
|
||||||
|
"roleId": "ROLE_ID"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "LEVEL_NUMBER",
|
||||||
|
"roleId": "ROLE_ID"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "LEVEL_NUMBER",
|
||||||
|
"roleId": "ROLE_ID"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"staffRoles": [
|
||||||
|
{
|
||||||
|
"name": "ROLE_NAME",
|
||||||
|
"roleId": "ROLE_ID"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ROLE_NAME",
|
||||||
|
"roleId": "ROLE_ID"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ROLE_NAME",
|
||||||
|
"roleId": "ROLE_ID"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"factPingRole": "FACT_OF_THE_DAY_ROLE_ID"
|
||||||
|
},
|
||||||
|
"leveling": {
|
||||||
|
"xpCooldown": "XP_COOLDOWN_IN_SECONDS",
|
||||||
|
"minXpAwarded": "MINIMUM_XP_AWARDED",
|
||||||
|
"maxXpAwarded": "MAXIMUM_XP_AWARDED"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,13 +2,13 @@ import fs from 'node:fs';
|
||||||
import { defineConfig } from 'drizzle-kit';
|
import { defineConfig } from 'drizzle-kit';
|
||||||
|
|
||||||
const config = JSON.parse(fs.readFileSync('./config.json', 'utf8'));
|
const config = JSON.parse(fs.readFileSync('./config.json', 'utf8'));
|
||||||
const { dbConnectionString } = config;
|
const { database } = config;
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
out: './drizzle',
|
out: './drizzle',
|
||||||
schema: './src/db/schema.ts',
|
schema: './src/db/schema.ts',
|
||||||
dialect: 'postgresql',
|
dialect: 'postgresql',
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: dbConnectionString,
|
url: database.dbConnectionString,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
17
package.json
17
package.json
|
@ -9,10 +9,13 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"compile": "npx tsc",
|
"compile": "npx tsc",
|
||||||
"target": "node ./target/discord-bot.js",
|
"target": "node ./target/discord-bot.js",
|
||||||
"start": "yarn run compile && yarn run target",
|
"start:dev": "yarn run compile && yarn run target",
|
||||||
|
"start:prod": "yarn compile && pm2 start ./target/discord-bot.js --name poixpixel-discord-bot",
|
||||||
|
"restart": "pm2 restart poixpixel-discord-bot",
|
||||||
"lint": "npx eslint ./src && npx tsc --noEmit",
|
"lint": "npx eslint ./src && npx tsc --noEmit",
|
||||||
"format": "prettier --check --ignore-path .prettierignore .",
|
"format": "prettier --check --ignore-path .prettierignore .",
|
||||||
"format:fix": "prettier --write --ignore-path .prettierignore ."
|
"format:fix": "prettier --write --ignore-path .prettierignore .",
|
||||||
|
"prepare": "ts-patch install -s && husky"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@napi-rs/canvas": "^0.1.69",
|
"@napi-rs/canvas": "^0.1.69",
|
||||||
|
@ -22,6 +25,8 @@
|
||||||
"pg": "^8.14.1"
|
"pg": "^8.14.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@commitlint/cli": "^19.8.0",
|
||||||
|
"@commitlint/config-conventional": "^19.8.0",
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"@eslint/js": "^9.24.0",
|
"@eslint/js": "^9.24.0",
|
||||||
"@microsoft/eslint-formatter-sarif": "^3.1.0",
|
"@microsoft/eslint-formatter-sarif": "^3.1.0",
|
||||||
|
@ -33,10 +38,14 @@
|
||||||
"eslint": "^9.24.0",
|
"eslint": "^9.24.0",
|
||||||
"eslint-config-prettier": "^10.1.2",
|
"eslint-config-prettier": "^10.1.2",
|
||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
|
"husky": "^9.1.7",
|
||||||
|
"lint-staged": "^15.5.0",
|
||||||
"prettier": "3.5.3",
|
"prettier": "3.5.3",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
|
"ts-patch": "^3.3.0",
|
||||||
"tsx": "^4.19.3",
|
"tsx": "^4.19.3",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3",
|
||||||
|
"typescript-transform-paths": "^3.5.5"
|
||||||
},
|
},
|
||||||
"packageManager": "yarn@4.6.0"
|
"packageManager": "yarn@4.9.1"
|
||||||
}
|
}
|
||||||
|
|
925
src/commands/fun/achievement.ts
Normal file
925
src/commands/fun/achievement.ts
Normal file
|
@ -0,0 +1,925 @@
|
||||||
|
import {
|
||||||
|
SlashCommandBuilder,
|
||||||
|
EmbedBuilder,
|
||||||
|
ActionRowBuilder,
|
||||||
|
StringSelectMenuBuilder,
|
||||||
|
StringSelectMenuOptionBuilder,
|
||||||
|
PermissionFlagsBits,
|
||||||
|
ChatInputCommandInteraction,
|
||||||
|
StringSelectMenuInteraction,
|
||||||
|
ComponentType,
|
||||||
|
ButtonInteraction,
|
||||||
|
} from 'discord.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAllAchievements,
|
||||||
|
getUserAchievements,
|
||||||
|
awardAchievement,
|
||||||
|
createAchievement,
|
||||||
|
deleteAchievement,
|
||||||
|
removeUserAchievement,
|
||||||
|
} from '@/db/db.js';
|
||||||
|
import { announceAchievement } from '@/util/achievementManager.js';
|
||||||
|
import { createPaginationButtons } from '@/util/helpers.js';
|
||||||
|
|
||||||
|
const command = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('achievement')
|
||||||
|
.setDescription('Manage server achievements')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('create')
|
||||||
|
.setDescription('Create a new achievement')
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('name')
|
||||||
|
.setDescription('Name of the achievement')
|
||||||
|
.setRequired(true),
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('description')
|
||||||
|
.setDescription('Description of the achievement')
|
||||||
|
.setRequired(true),
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('requirement_type')
|
||||||
|
.setDescription('Type of requirement for this achievement')
|
||||||
|
.setRequired(true)
|
||||||
|
.addChoices(
|
||||||
|
{ name: 'Message Count', value: 'message_count' },
|
||||||
|
{ name: 'Level', value: 'level' },
|
||||||
|
{ name: 'Reactions', value: 'reactions' },
|
||||||
|
{ name: 'Command Usage', value: 'command_usage' },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.addIntegerOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('threshold')
|
||||||
|
.setDescription('Threshold value for completing the achievement')
|
||||||
|
.setRequired(true),
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('image_url')
|
||||||
|
.setDescription('URL for the achievement image (optional)')
|
||||||
|
.setRequired(false),
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('command_name')
|
||||||
|
.setDescription('Command name (only for command_usage type)')
|
||||||
|
.setRequired(false),
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('reward_type')
|
||||||
|
.setDescription('Type of reward (optional)')
|
||||||
|
.setRequired(false)
|
||||||
|
.addChoices(
|
||||||
|
{ name: 'XP', value: 'xp' },
|
||||||
|
{ name: 'Role', value: 'role' },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('reward_value')
|
||||||
|
.setDescription('Value of the reward (XP amount or role ID)')
|
||||||
|
.setRequired(false),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('delete')
|
||||||
|
.setDescription('Delete an achievement')
|
||||||
|
.addIntegerOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('id')
|
||||||
|
.setDescription('ID of the achievement to delete')
|
||||||
|
.setRequired(true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('award')
|
||||||
|
.setDescription('Award an achievement to a user')
|
||||||
|
.addUserOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('user')
|
||||||
|
.setDescription('User to award the achievement to')
|
||||||
|
.setRequired(true),
|
||||||
|
)
|
||||||
|
.addIntegerOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('achievement_id')
|
||||||
|
.setDescription('ID of the achievement to award')
|
||||||
|
.setRequired(true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('view')
|
||||||
|
.setDescription('View a users achievements')
|
||||||
|
.addUserOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('user')
|
||||||
|
.setDescription('User to view achievements for')
|
||||||
|
.setRequired(false),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('unaward')
|
||||||
|
.setDescription('Remove an achievement from a user')
|
||||||
|
.addUserOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('user')
|
||||||
|
.setDescription('User to remove the achievement from')
|
||||||
|
.setRequired(true),
|
||||||
|
)
|
||||||
|
.addIntegerOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('achievement_id')
|
||||||
|
.setDescription('ID of the achievement to remove')
|
||||||
|
.setRequired(true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
async execute(interaction: ChatInputCommandInteraction) {
|
||||||
|
await interaction.deferReply();
|
||||||
|
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
|
||||||
|
switch (subcommand) {
|
||||||
|
case 'create':
|
||||||
|
await handleCreateAchievement(interaction);
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
await handleDeleteAchievement(interaction);
|
||||||
|
break;
|
||||||
|
case 'award':
|
||||||
|
await handleAwardAchievement(interaction);
|
||||||
|
break;
|
||||||
|
case 'unaward':
|
||||||
|
await handleUnawardAchievement(interaction);
|
||||||
|
break;
|
||||||
|
case 'view':
|
||||||
|
await handleViewUserAchievements(interaction);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handleCreateAchievement(
|
||||||
|
interaction: ChatInputCommandInteraction,
|
||||||
|
) {
|
||||||
|
const name = interaction.options.getString('name')!;
|
||||||
|
const description = interaction.options.getString('description')!;
|
||||||
|
const imageUrl = interaction.options.getString('image_url');
|
||||||
|
const requirementType = interaction.options.getString('requirement_type')!;
|
||||||
|
const threshold = interaction.options.getInteger('threshold')!;
|
||||||
|
const commandName = interaction.options.getString('command_name');
|
||||||
|
const rewardType = interaction.options.getString('reward_type');
|
||||||
|
const rewardValue = interaction.options.getString('reward_value');
|
||||||
|
|
||||||
|
if (requirementType === 'command_usage' && !commandName) {
|
||||||
|
await interaction.editReply(
|
||||||
|
'Command name is required for command_usage type achievements.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rewardType && !rewardValue) {
|
||||||
|
await interaction.editReply(
|
||||||
|
`Reward value is required when setting a ${rewardType} reward.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requirement: any = {};
|
||||||
|
if (requirementType === 'command_usage' && commandName) {
|
||||||
|
requirement.command = commandName;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const achievement = await createAchievement({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
imageUrl: imageUrl || undefined,
|
||||||
|
requirementType,
|
||||||
|
threshold,
|
||||||
|
requirement,
|
||||||
|
rewardType: rewardType || undefined,
|
||||||
|
rewardValue: rewardValue || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (achievement) {
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setColor(0x00ff00)
|
||||||
|
.setTitle('Achievement Created')
|
||||||
|
.setDescription(`Successfully created achievement: **${name}**`)
|
||||||
|
.addFields(
|
||||||
|
{ name: 'ID', value: `${achievement.id}`, inline: true },
|
||||||
|
{ name: 'Type', value: requirementType, inline: true },
|
||||||
|
{ name: 'Threshold', value: `${threshold}`, inline: true },
|
||||||
|
{ name: 'Description', value: description },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rewardType && rewardValue) {
|
||||||
|
embed.addFields({
|
||||||
|
name: 'Reward',
|
||||||
|
value: `${rewardType === 'xp' ? `${rewardValue} XP` : `<@&${rewardValue}>`}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
} else {
|
||||||
|
await interaction.editReply('Failed to create achievement.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating achievement:', error);
|
||||||
|
await interaction.editReply(
|
||||||
|
'An error occurred while creating the achievement.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteAchievement(
|
||||||
|
interaction: ChatInputCommandInteraction,
|
||||||
|
) {
|
||||||
|
const achievementId = interaction.options.getInteger('id')!;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const success = await deleteAchievement(achievementId);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
await interaction.editReply(
|
||||||
|
`Achievement with ID ${achievementId} has been deleted.`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await interaction.editReply(
|
||||||
|
`Failed to delete achievement with ID ${achievementId}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting achievement:', error);
|
||||||
|
await interaction.editReply(
|
||||||
|
'An error occurred while deleting the achievement.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAwardAchievement(
|
||||||
|
interaction: ChatInputCommandInteraction,
|
||||||
|
) {
|
||||||
|
const user = interaction.options.getUser('user')!;
|
||||||
|
const achievementId = interaction.options.getInteger('achievement_id')!;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allAchievements = await getAllAchievements();
|
||||||
|
const achievement = allAchievements.find((a) => a.id === achievementId);
|
||||||
|
|
||||||
|
if (!achievement) {
|
||||||
|
await interaction.editReply(
|
||||||
|
`Achievement with ID ${achievementId} not found.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await awardAchievement(user.id, achievementId);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
await announceAchievement(interaction.guild!, user.id, achievement);
|
||||||
|
await interaction.editReply(
|
||||||
|
`Achievement "${achievement.name}" awarded to ${user}.`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await interaction.editReply(
|
||||||
|
'Failed to award achievement or user already has this achievement.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error awarding achievement:', error);
|
||||||
|
await interaction.editReply(
|
||||||
|
'An error occurred while awarding the achievement.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleViewUserAchievements(
|
||||||
|
interaction: ChatInputCommandInteraction,
|
||||||
|
) {
|
||||||
|
const targetUser = interaction.options.getUser('user') || interaction.user;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userAchievements = await getUserAchievements(targetUser.id);
|
||||||
|
const allAchievements = await getAllAchievements();
|
||||||
|
|
||||||
|
const totalAchievements = allAchievements.length;
|
||||||
|
const earnedCount = userAchievements.filter((ua) => ua.earnedAt).length;
|
||||||
|
const overallProgress =
|
||||||
|
totalAchievements > 0
|
||||||
|
? Math.round((earnedCount / totalAchievements) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if (totalAchievements === 0) {
|
||||||
|
await interaction.editReply(
|
||||||
|
'No achievements have been created on this server yet.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const earnedAchievements = userAchievements
|
||||||
|
.filter((ua) => {
|
||||||
|
return (
|
||||||
|
ua.earnedAt &&
|
||||||
|
ua.earnedAt !== null &&
|
||||||
|
ua.earnedAt !== undefined &&
|
||||||
|
new Date(ua.earnedAt).getTime() > 0
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((ua) => {
|
||||||
|
const achievementDef = allAchievements.find(
|
||||||
|
(a) => a.id === ua.achievementId,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...ua,
|
||||||
|
definition: achievementDef,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((a) => a.definition);
|
||||||
|
|
||||||
|
const inProgressAchievements = userAchievements
|
||||||
|
.filter((ua) => {
|
||||||
|
return (
|
||||||
|
(!ua.earnedAt ||
|
||||||
|
ua.earnedAt === null ||
|
||||||
|
ua.earnedAt === undefined ||
|
||||||
|
new Date(ua.earnedAt).getTime() <= 0) &&
|
||||||
|
(ua.progress ?? 0) > 0
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((ua) => {
|
||||||
|
const achievementDef = allAchievements.find(
|
||||||
|
(a) => a.id === ua.achievementId,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...ua,
|
||||||
|
definition: achievementDef,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((a) => a.definition);
|
||||||
|
|
||||||
|
const earnedAndInProgressIds = new Set(
|
||||||
|
userAchievements
|
||||||
|
.filter(
|
||||||
|
(ua) =>
|
||||||
|
(ua.progress ?? 0) > 0 ||
|
||||||
|
(ua.earnedAt && new Date(ua.earnedAt).getTime() > 0),
|
||||||
|
)
|
||||||
|
.map((ua) => ua.achievementId),
|
||||||
|
);
|
||||||
|
const availableAchievements = allAchievements
|
||||||
|
.filter((a) => !earnedAndInProgressIds.has(a.id))
|
||||||
|
.map((definition) => {
|
||||||
|
const existingEntry = userAchievements.find(
|
||||||
|
(ua) =>
|
||||||
|
ua.achievementId === definition.id &&
|
||||||
|
(ua.progress === 0 || ua.progress === null),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
achievementId: definition.id,
|
||||||
|
progress: existingEntry?.progress || 0,
|
||||||
|
definition,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
interface AchievementViewOption {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options: AchievementViewOption[] = [];
|
||||||
|
|
||||||
|
if (earnedAchievements.length > 0) {
|
||||||
|
options.push({
|
||||||
|
label: 'Earned Achievements',
|
||||||
|
value: 'earned',
|
||||||
|
count: earnedAchievements.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inProgressAchievements.length > 0) {
|
||||||
|
options.push({
|
||||||
|
label: 'In Progress',
|
||||||
|
value: 'progress',
|
||||||
|
count: inProgressAchievements.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (availableAchievements.length > 0) {
|
||||||
|
options.push({
|
||||||
|
label: 'Available Achievements',
|
||||||
|
value: 'available',
|
||||||
|
count: availableAchievements.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.length === 0) {
|
||||||
|
await interaction.editReply('No achievement data found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let initialOption = options[0].value;
|
||||||
|
for (const preferredType of ['earned', 'progress', 'available']) {
|
||||||
|
const found = options.find((opt) => opt.value === preferredType);
|
||||||
|
if (found) {
|
||||||
|
initialOption = preferredType;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialEmbedData =
|
||||||
|
initialOption === 'earned'
|
||||||
|
? { achievements: earnedAchievements, title: 'Earned Achievements' }
|
||||||
|
: initialOption === 'progress'
|
||||||
|
? {
|
||||||
|
achievements: inProgressAchievements,
|
||||||
|
title: 'Achievements In Progress',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
achievements: availableAchievements,
|
||||||
|
title: 'Available Achievements',
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialEmbed = createAchievementsEmbed(
|
||||||
|
initialEmbedData.achievements,
|
||||||
|
initialEmbedData.title,
|
||||||
|
targetUser,
|
||||||
|
overallProgress,
|
||||||
|
earnedCount,
|
||||||
|
totalAchievements,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Define pagination variables
|
||||||
|
const achievementsPerPage = 5;
|
||||||
|
let currentPage = 0;
|
||||||
|
|
||||||
|
const pages = splitAchievementsIntoPages(
|
||||||
|
initialEmbedData.achievements,
|
||||||
|
initialEmbedData.title,
|
||||||
|
targetUser,
|
||||||
|
overallProgress,
|
||||||
|
earnedCount,
|
||||||
|
totalAchievements,
|
||||||
|
achievementsPerPage,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create achievements type selector
|
||||||
|
const selectMenu =
|
||||||
|
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
||||||
|
new StringSelectMenuBuilder()
|
||||||
|
.setCustomId('achievement_view')
|
||||||
|
.setPlaceholder('Select achievement type')
|
||||||
|
.addOptions(
|
||||||
|
options.map((opt) =>
|
||||||
|
new StringSelectMenuOptionBuilder()
|
||||||
|
.setLabel(`${opt.label} (${opt.count})`)
|
||||||
|
.setValue(opt.value)
|
||||||
|
.setDefault(opt.value === initialOption),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create pagination buttons
|
||||||
|
const paginationRow = createPaginationButtons(pages.length, currentPage);
|
||||||
|
|
||||||
|
const message = await interaction.editReply({
|
||||||
|
embeds: [pages[currentPage]],
|
||||||
|
components: [selectMenu, ...(pages.length > 1 ? [paginationRow] : [])],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options.length <= 1 && pages.length <= 1) return;
|
||||||
|
|
||||||
|
// Create collector for both select menu and button interactions
|
||||||
|
const collector = message.createMessageComponentCollector({
|
||||||
|
componentType: ComponentType.StringSelect,
|
||||||
|
time: 60000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const buttonCollector = message.createMessageComponentCollector({
|
||||||
|
componentType: ComponentType.Button,
|
||||||
|
time: 60000,
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on('collect', async (i: StringSelectMenuInteraction) => {
|
||||||
|
if (i.user.id !== interaction.user.id) {
|
||||||
|
await i.reply({
|
||||||
|
content: 'You cannot use this menu.',
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await i.deferUpdate();
|
||||||
|
|
||||||
|
const selected = i.values[0];
|
||||||
|
let categoryPages;
|
||||||
|
let selectedAchievements;
|
||||||
|
|
||||||
|
if (selected === 'earned') {
|
||||||
|
selectedAchievements = earnedAchievements;
|
||||||
|
categoryPages = splitAchievementsIntoPages(
|
||||||
|
earnedAchievements,
|
||||||
|
'Earned Achievements',
|
||||||
|
targetUser,
|
||||||
|
overallProgress,
|
||||||
|
earnedCount,
|
||||||
|
totalAchievements,
|
||||||
|
achievementsPerPage,
|
||||||
|
);
|
||||||
|
} else if (selected === 'progress') {
|
||||||
|
selectedAchievements = inProgressAchievements;
|
||||||
|
categoryPages = splitAchievementsIntoPages(
|
||||||
|
inProgressAchievements,
|
||||||
|
'Achievements In Progress',
|
||||||
|
targetUser,
|
||||||
|
overallProgress,
|
||||||
|
earnedCount,
|
||||||
|
totalAchievements,
|
||||||
|
achievementsPerPage,
|
||||||
|
);
|
||||||
|
} else if (selected === 'available') {
|
||||||
|
selectedAchievements = availableAchievements;
|
||||||
|
categoryPages = splitAchievementsIntoPages(
|
||||||
|
availableAchievements,
|
||||||
|
'Available Achievements',
|
||||||
|
targetUser,
|
||||||
|
overallProgress,
|
||||||
|
earnedCount,
|
||||||
|
totalAchievements,
|
||||||
|
achievementsPerPage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (categoryPages && categoryPages.length > 0) {
|
||||||
|
currentPage = 0;
|
||||||
|
pages.splice(0, pages.length, ...categoryPages);
|
||||||
|
|
||||||
|
const updatedSelectMenu =
|
||||||
|
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
||||||
|
new StringSelectMenuBuilder()
|
||||||
|
.setCustomId('achievement_view')
|
||||||
|
.setPlaceholder('Select achievement type')
|
||||||
|
.addOptions(
|
||||||
|
options.map((opt) =>
|
||||||
|
new StringSelectMenuOptionBuilder()
|
||||||
|
.setLabel(`${opt.label} (${opt.count})`)
|
||||||
|
.setValue(opt.value)
|
||||||
|
.setDefault(opt.value === selected),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedPaginationRow = createPaginationButtons(
|
||||||
|
pages.length,
|
||||||
|
currentPage,
|
||||||
|
);
|
||||||
|
|
||||||
|
await i.editReply({
|
||||||
|
embeds: [pages[currentPage]],
|
||||||
|
components: [
|
||||||
|
updatedSelectMenu,
|
||||||
|
...(pages.length > 1 ? [updatedPaginationRow] : []),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
buttonCollector.on('collect', async (i: ButtonInteraction) => {
|
||||||
|
if (i.user.id !== interaction.user.id) {
|
||||||
|
await i.reply({
|
||||||
|
content: 'You cannot use these buttons.',
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await i.deferUpdate();
|
||||||
|
|
||||||
|
if (i.customId === 'first') {
|
||||||
|
currentPage = 0;
|
||||||
|
} else if (i.customId === 'prev') {
|
||||||
|
currentPage = Math.max(0, currentPage - 1);
|
||||||
|
} else if (i.customId === 'next') {
|
||||||
|
currentPage = Math.min(pages.length - 1, currentPage + 1);
|
||||||
|
} else if (i.customId === 'last') {
|
||||||
|
currentPage = pages.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedPaginationRow = createPaginationButtons(
|
||||||
|
pages.length,
|
||||||
|
currentPage,
|
||||||
|
);
|
||||||
|
|
||||||
|
await i.editReply({
|
||||||
|
embeds: [pages[currentPage]],
|
||||||
|
components: [selectMenu, updatedPaginationRow],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on('end', () => {
|
||||||
|
buttonCollector.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
buttonCollector.on('end', () => {
|
||||||
|
interaction.editReply({ components: [] }).catch((err) => {
|
||||||
|
console.error('Failed to edit reply after collector ended.', err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error viewing user achievements:', error);
|
||||||
|
await interaction.editReply(
|
||||||
|
'An error occurred while fetching user achievements.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle removing an achievement from a user
|
||||||
|
*/
|
||||||
|
async function handleUnawardAchievement(
|
||||||
|
interaction: ChatInputCommandInteraction,
|
||||||
|
) {
|
||||||
|
const user = interaction.options.getUser('user')!;
|
||||||
|
const achievementId = interaction.options.getInteger('achievement_id')!;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allAchievements = await getAllAchievements();
|
||||||
|
const achievement = allAchievements.find((a) => a.id === achievementId);
|
||||||
|
|
||||||
|
if (!achievement) {
|
||||||
|
await interaction.editReply(
|
||||||
|
`Achievement with ID ${achievementId} not found.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userAchievements = await getUserAchievements(user.id);
|
||||||
|
const earnedAchievement = userAchievements.find(
|
||||||
|
(ua) => ua.achievementId === achievementId && ua.earnedAt !== null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!earnedAchievement) {
|
||||||
|
await interaction.editReply(
|
||||||
|
`${user.username} has not earned the achievement "${achievement.name}".`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await removeUserAchievement(user.id, achievementId);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
await interaction.editReply(
|
||||||
|
`Achievement "${achievement.name}" has been removed from ${user.username}.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (achievement.rewardType === 'role' && achievement.rewardValue) {
|
||||||
|
try {
|
||||||
|
const member = await interaction.guild!.members.fetch(user.id);
|
||||||
|
await member.roles.remove(achievement.rewardValue);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`Failed to remove role ${achievement.rewardValue} from user ${user.id}`,
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
await interaction.followUp({
|
||||||
|
content:
|
||||||
|
'Note: Failed to remove the role reward. Please check permissions and remove it manually if needed.',
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await interaction.editReply(
|
||||||
|
`Failed to remove achievement "${achievement.name}" from ${user.username}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing achievement from user:', error);
|
||||||
|
await interaction.editReply(
|
||||||
|
'An error occurred while removing the achievement.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAchievementsEmbed(
|
||||||
|
achievements: Array<any>,
|
||||||
|
title: string,
|
||||||
|
user: any,
|
||||||
|
overallProgress: number = 0,
|
||||||
|
earnedCount: number = 0,
|
||||||
|
totalAchievements: number = 0,
|
||||||
|
) {
|
||||||
|
return createPageEmbed(
|
||||||
|
achievements,
|
||||||
|
title,
|
||||||
|
user,
|
||||||
|
overallProgress,
|
||||||
|
earnedCount,
|
||||||
|
totalAchievements,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a visual progress bar
|
||||||
|
* @param progress - Number between 0-100
|
||||||
|
* @returns A string representing a progress bar
|
||||||
|
*/
|
||||||
|
function createProgressBar(progress: number): string {
|
||||||
|
const filledBars = Math.round(progress / 10);
|
||||||
|
const emptyBars = 10 - filledBars;
|
||||||
|
|
||||||
|
const filled = '█'.repeat(filledBars);
|
||||||
|
const empty = '░'.repeat(emptyBars);
|
||||||
|
|
||||||
|
return `[${filled}${empty}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatType(type: string): string {
|
||||||
|
return type.charAt(0).toUpperCase() + type.slice(1).replace('_', ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits achievements into pages for pagination
|
||||||
|
*/
|
||||||
|
function splitAchievementsIntoPages(
|
||||||
|
achievements: Array<any>,
|
||||||
|
title: string,
|
||||||
|
user: any,
|
||||||
|
overallProgress: number = 0,
|
||||||
|
earnedCount: number = 0,
|
||||||
|
totalAchievements: number = 0,
|
||||||
|
achievementsPerPage: number = 5,
|
||||||
|
): EmbedBuilder[] {
|
||||||
|
if (achievements.length === 0) {
|
||||||
|
return [
|
||||||
|
createAchievementsEmbed(
|
||||||
|
achievements,
|
||||||
|
title,
|
||||||
|
user,
|
||||||
|
overallProgress,
|
||||||
|
earnedCount,
|
||||||
|
totalAchievements,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupedAchievements: Record<string, typeof achievements> = {
|
||||||
|
message_count: achievements.filter(
|
||||||
|
(a) => a.definition?.requirementType === 'message_count',
|
||||||
|
),
|
||||||
|
level: achievements.filter(
|
||||||
|
(a) => a.definition?.requirementType === 'level',
|
||||||
|
),
|
||||||
|
command_usage: achievements.filter(
|
||||||
|
(a) => a.definition?.requirementType === 'command_usage',
|
||||||
|
),
|
||||||
|
reactions: achievements.filter(
|
||||||
|
(a) => a.definition?.requirementType === 'reactions',
|
||||||
|
),
|
||||||
|
other: achievements.filter(
|
||||||
|
(a) =>
|
||||||
|
!['message_count', 'level', 'command_usage', 'reactions'].includes(
|
||||||
|
a.definition?.requirementType,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let orderedAchievements: typeof achievements = [];
|
||||||
|
for (const [type, typeAchievements] of Object.entries(groupedAchievements)) {
|
||||||
|
if (typeAchievements.length > 0) {
|
||||||
|
orderedAchievements = orderedAchievements.concat(
|
||||||
|
typeAchievements.map((ach) => ({
|
||||||
|
...ach,
|
||||||
|
achievementType: type,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: (typeof achievements)[] = [];
|
||||||
|
for (let i = 0; i < orderedAchievements.length; i += achievementsPerPage) {
|
||||||
|
chunks.push(orderedAchievements.slice(i, i + achievementsPerPage));
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks.map((chunk, index) => {
|
||||||
|
return createPageEmbed(
|
||||||
|
chunk,
|
||||||
|
title,
|
||||||
|
user,
|
||||||
|
overallProgress,
|
||||||
|
earnedCount,
|
||||||
|
totalAchievements,
|
||||||
|
index + 1,
|
||||||
|
chunks.length,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an embed for a single page of achievements
|
||||||
|
*/
|
||||||
|
function createPageEmbed(
|
||||||
|
achievements: Array<any>,
|
||||||
|
title: string,
|
||||||
|
user: any,
|
||||||
|
overallProgress: number = 0,
|
||||||
|
earnedCount: number = 0,
|
||||||
|
totalAchievements: number = 0,
|
||||||
|
pageNumber: number = 1,
|
||||||
|
totalPages: number = 1,
|
||||||
|
): EmbedBuilder {
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setColor(0x0099ff)
|
||||||
|
.setTitle(`${user.username}'s ${title}`)
|
||||||
|
.setThumbnail(user.displayAvatarURL())
|
||||||
|
.setFooter({ text: `Page ${pageNumber}/${totalPages}` });
|
||||||
|
|
||||||
|
if (achievements.length === 0) {
|
||||||
|
embed.setDescription('No achievements found.');
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentType: string | null = null;
|
||||||
|
|
||||||
|
achievements.forEach((achievement) => {
|
||||||
|
const { definition, achievementType } = achievement;
|
||||||
|
if (!definition) return;
|
||||||
|
|
||||||
|
if (achievementType && achievementType !== currentType) {
|
||||||
|
currentType = achievementType;
|
||||||
|
embed.addFields({
|
||||||
|
name: `${formatType(currentType || '')} Achievements`,
|
||||||
|
value: '⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let fieldValue = definition.description;
|
||||||
|
|
||||||
|
if (
|
||||||
|
achievement.earnedAt &&
|
||||||
|
achievement.earnedAt !== null &&
|
||||||
|
achievement.earnedAt !== undefined &&
|
||||||
|
new Date(achievement.earnedAt).getTime() > 0
|
||||||
|
) {
|
||||||
|
const earnedDate = new Date(achievement.earnedAt);
|
||||||
|
fieldValue += `\n✅ **Completed**: <t:${Math.floor(earnedDate.getTime() / 1000)}:R>`;
|
||||||
|
} else {
|
||||||
|
const progress = achievement.progress || 0;
|
||||||
|
const progressBar = createProgressBar(progress);
|
||||||
|
fieldValue += `\n${progressBar} **${progress}%**`;
|
||||||
|
|
||||||
|
if (definition.requirementType === 'message_count') {
|
||||||
|
fieldValue += `\n📨 Send ${definition.threshold} messages`;
|
||||||
|
} else if (definition.requirementType === 'level') {
|
||||||
|
fieldValue += `\n🏆 Reach level ${definition.threshold}`;
|
||||||
|
} else if (definition.requirementType === 'command_usage') {
|
||||||
|
const cmdName = definition.requirement?.command || 'unknown';
|
||||||
|
fieldValue += `\n🔧 Use /${cmdName} command`;
|
||||||
|
} else if (definition.requirementType === 'reactions') {
|
||||||
|
fieldValue += `\n😀 Add ${definition.threshold} reactions`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (definition.rewardType && definition.rewardValue) {
|
||||||
|
fieldValue += `\n💰 **Reward**: ${
|
||||||
|
definition.rewardType === 'xp'
|
||||||
|
? `${definition.rewardValue} XP`
|
||||||
|
: `Role <@&${definition.rewardValue}>`
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
embed.addFields({
|
||||||
|
name: definition.name,
|
||||||
|
value: fieldValue,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
embed.addFields({
|
||||||
|
name: '📊 Overall Achievement Progress',
|
||||||
|
value:
|
||||||
|
`${createProgressBar(overallProgress)} **${overallProgress}%**\n` +
|
||||||
|
`You've earned **${earnedCount}** of ${totalAchievements} achievements`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default command;
|
117
src/commands/fun/counting.ts
Normal file
117
src/commands/fun/counting.ts
Normal file
|
@ -0,0 +1,117 @@
|
||||||
|
import {
|
||||||
|
SlashCommandBuilder,
|
||||||
|
EmbedBuilder,
|
||||||
|
PermissionsBitField,
|
||||||
|
} from 'discord.js';
|
||||||
|
|
||||||
|
import { SubcommandCommand } from '@/types/CommandTypes.js';
|
||||||
|
import { getCountingData, setCount } from '@/util/countingManager.js';
|
||||||
|
import { loadConfig } from '@/util/configLoader.js';
|
||||||
|
|
||||||
|
const command: SubcommandCommand = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('counting')
|
||||||
|
.setDescription('Commands related to the counting channel')
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('status')
|
||||||
|
.setDescription('Check the current counting status'),
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('setcount')
|
||||||
|
.setDescription(
|
||||||
|
'Set the current count to a specific number (Admin only)',
|
||||||
|
)
|
||||||
|
.addIntegerOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('count')
|
||||||
|
.setDescription('The number to set as the current count')
|
||||||
|
.setRequired(true)
|
||||||
|
.setMinValue(0),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
execute: async (interaction) => {
|
||||||
|
if (!interaction.isChatInputCommand()) return;
|
||||||
|
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
|
||||||
|
if (subcommand === 'status') {
|
||||||
|
const countingData = await getCountingData();
|
||||||
|
const countingChannelId = loadConfig().channels.counting;
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle('Counting Channel Status')
|
||||||
|
.setColor(0x0099ff)
|
||||||
|
.addFields(
|
||||||
|
{
|
||||||
|
name: 'Current Count',
|
||||||
|
value: countingData.currentCount.toString(),
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Next Number',
|
||||||
|
value: (countingData.currentCount + 1).toString(),
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Highest Count',
|
||||||
|
value: countingData.highestCount.toString(),
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Total Correct Counts',
|
||||||
|
value: countingData.totalCorrect.toString(),
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Counting Channel',
|
||||||
|
value: `<#${countingChannelId}>`,
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.setFooter({ text: 'Remember: No user can count twice in a row!' })
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
if (countingData.lastUserId) {
|
||||||
|
embed.addFields({
|
||||||
|
name: 'Last Counter',
|
||||||
|
value: `<@${countingData.lastUserId}>`,
|
||||||
|
inline: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.reply({ embeds: [embed] });
|
||||||
|
} else if (subcommand === 'setcount') {
|
||||||
|
if (
|
||||||
|
!interaction.memberPermissions?.has(
|
||||||
|
PermissionsBitField.Flags.Administrator,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'You need administrator permissions to use this command.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = interaction.options.getInteger('count');
|
||||||
|
if (count === null) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'Invalid count specified.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await setCount(count);
|
||||||
|
await interaction.reply({
|
||||||
|
content: `Count has been set to **${count}**. The next number should be **${count + 1}**.`,
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default command;
|
312
src/commands/fun/fact.ts
Normal file
312
src/commands/fun/fact.ts
Normal file
|
@ -0,0 +1,312 @@
|
||||||
|
import {
|
||||||
|
SlashCommandBuilder,
|
||||||
|
PermissionsBitField,
|
||||||
|
EmbedBuilder,
|
||||||
|
ActionRowBuilder,
|
||||||
|
ButtonBuilder,
|
||||||
|
ButtonStyle,
|
||||||
|
} from 'discord.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
addFact,
|
||||||
|
getPendingFacts,
|
||||||
|
approveFact,
|
||||||
|
deleteFact,
|
||||||
|
getLastInsertedFactId,
|
||||||
|
} from '@/db/db.js';
|
||||||
|
import { postFactOfTheDay } from '@/util/factManager.js';
|
||||||
|
import { loadConfig } from '@/util/configLoader.js';
|
||||||
|
import { SubcommandCommand } from '@/types/CommandTypes.js';
|
||||||
|
import { createPaginationButtons } from '@/util/helpers.js';
|
||||||
|
|
||||||
|
const command: SubcommandCommand = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('fact')
|
||||||
|
.setDescription('Manage facts of the day')
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('submit')
|
||||||
|
.setDescription('Submit a new fact for approval')
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('content')
|
||||||
|
.setDescription('The fact content')
|
||||||
|
.setRequired(true),
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('source')
|
||||||
|
.setDescription('Source of the fact (optional)')
|
||||||
|
.setRequired(false),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('approve')
|
||||||
|
.setDescription('Approve a pending fact (Mod only)')
|
||||||
|
.addIntegerOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('id')
|
||||||
|
.setDescription('The ID of the fact to approve')
|
||||||
|
.setRequired(true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('delete')
|
||||||
|
.setDescription('Delete a fact (Mod only)')
|
||||||
|
.addIntegerOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('id')
|
||||||
|
.setDescription('The ID of the fact to delete')
|
||||||
|
.setRequired(true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('pending')
|
||||||
|
.setDescription('List all pending facts (Mod only)'),
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('post')
|
||||||
|
.setDescription('Post a fact of the day manually (Admin only)'),
|
||||||
|
),
|
||||||
|
|
||||||
|
execute: async (interaction) => {
|
||||||
|
if (!interaction.isChatInputCommand()) return;
|
||||||
|
|
||||||
|
await interaction.deferReply({
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
await interaction.editReply('Processing...');
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
|
||||||
|
if (subcommand === 'submit') {
|
||||||
|
const content = interaction.options.getString('content', true);
|
||||||
|
const source = interaction.options.getString('source') || undefined;
|
||||||
|
|
||||||
|
const isAdmin = interaction.memberPermissions?.has(
|
||||||
|
PermissionsBitField.Flags.Administrator,
|
||||||
|
);
|
||||||
|
|
||||||
|
await addFact({
|
||||||
|
content,
|
||||||
|
source,
|
||||||
|
addedBy: interaction.user.id,
|
||||||
|
approved: isAdmin ? true : false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
const approvalChannel = interaction.guild?.channels.cache.get(
|
||||||
|
config.channels.factApproval,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (approvalChannel?.isTextBased()) {
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle('New Fact Submission')
|
||||||
|
.setDescription(content)
|
||||||
|
.setColor(0x0099ff)
|
||||||
|
.addFields(
|
||||||
|
{
|
||||||
|
name: 'Submitted By',
|
||||||
|
value: `<@${interaction.user.id}>`,
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
{ name: 'Source', value: source || 'Not provided', inline: true },
|
||||||
|
)
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
const factId = await getLastInsertedFactId();
|
||||||
|
|
||||||
|
const approveButton = new ButtonBuilder()
|
||||||
|
.setCustomId(`approve_fact_${factId}`)
|
||||||
|
.setLabel('Approve')
|
||||||
|
.setStyle(ButtonStyle.Success);
|
||||||
|
|
||||||
|
const rejectButton = new ButtonBuilder()
|
||||||
|
.setCustomId(`reject_fact_${factId}`)
|
||||||
|
.setLabel('Reject')
|
||||||
|
.setStyle(ButtonStyle.Danger);
|
||||||
|
|
||||||
|
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
approveButton,
|
||||||
|
rejectButton,
|
||||||
|
);
|
||||||
|
|
||||||
|
await approvalChannel.send({
|
||||||
|
embeds: [embed],
|
||||||
|
components: [row],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.error('Approval channel not found or is not a text channel');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
content: isAdmin
|
||||||
|
? 'Your fact has been automatically approved and added to the database!'
|
||||||
|
: 'Your fact has been submitted for approval!',
|
||||||
|
});
|
||||||
|
} else if (subcommand === 'approve') {
|
||||||
|
if (
|
||||||
|
!interaction.memberPermissions?.has(
|
||||||
|
PermissionsBitField.Flags.ModerateMembers,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: 'You do not have permission to approve facts.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = interaction.options.getInteger('id', true);
|
||||||
|
await approveFact(id);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Fact #${id} has been approved!`,
|
||||||
|
});
|
||||||
|
} else if (subcommand === 'delete') {
|
||||||
|
if (
|
||||||
|
!interaction.memberPermissions?.has(
|
||||||
|
PermissionsBitField.Flags.ModerateMembers,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: 'You do not have permission to delete facts.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = interaction.options.getInteger('id', true);
|
||||||
|
await deleteFact(id);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Fact #${id} has been deleted!`,
|
||||||
|
});
|
||||||
|
} else if (subcommand === 'pending') {
|
||||||
|
if (
|
||||||
|
!interaction.memberPermissions?.has(
|
||||||
|
PermissionsBitField.Flags.ModerateMembers,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: 'You do not have permission to view pending facts.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FACTS_PER_PAGE = 5;
|
||||||
|
const pendingFacts = await getPendingFacts();
|
||||||
|
|
||||||
|
if (pendingFacts.length === 0) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: 'There are no pending facts.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages: EmbedBuilder[] = [];
|
||||||
|
for (let i = 0; i < pendingFacts.length; i += FACTS_PER_PAGE) {
|
||||||
|
const pageFacts = pendingFacts.slice(i, i + FACTS_PER_PAGE);
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle('Pending Facts')
|
||||||
|
.setColor(0x0099ff)
|
||||||
|
.setDescription(
|
||||||
|
pageFacts
|
||||||
|
.map((fact) => {
|
||||||
|
return `**ID #${fact.id}**\n${fact.content}\nSubmitted by: <@${fact.addedBy}>\nSource: ${fact.source || 'Not provided'}`;
|
||||||
|
})
|
||||||
|
.join('\n\n'),
|
||||||
|
)
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
pages.push(embed);
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentPage = 0;
|
||||||
|
|
||||||
|
const message = await interaction.editReply({
|
||||||
|
embeds: [pages[currentPage]],
|
||||||
|
components: [createPaginationButtons(pages.length, currentPage)],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pages.length <= 1) return;
|
||||||
|
|
||||||
|
const collector = message.createMessageComponentCollector({
|
||||||
|
time: 300000,
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on('collect', async (i) => {
|
||||||
|
if (i.user.id !== interaction.user.id) {
|
||||||
|
await i.reply({
|
||||||
|
content: 'These controls are not for you!',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i.isButton()) {
|
||||||
|
switch (i.customId) {
|
||||||
|
case 'first':
|
||||||
|
currentPage = 0;
|
||||||
|
break;
|
||||||
|
case 'prev':
|
||||||
|
if (currentPage > 0) currentPage--;
|
||||||
|
break;
|
||||||
|
case 'next':
|
||||||
|
if (currentPage < pages.length - 1) currentPage++;
|
||||||
|
break;
|
||||||
|
case 'last':
|
||||||
|
currentPage = pages.length - 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i.isStringSelectMenu()) {
|
||||||
|
const selected = parseInt(i.values[0]);
|
||||||
|
if (!isNaN(selected) && selected >= 0 && selected < pages.length) {
|
||||||
|
currentPage = selected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await i.update({
|
||||||
|
embeds: [pages[currentPage]],
|
||||||
|
components: [createPaginationButtons(pages.length, currentPage)],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on('end', async () => {
|
||||||
|
if (message) {
|
||||||
|
try {
|
||||||
|
await interaction.editReply({ components: [] });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing components:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (subcommand === 'post') {
|
||||||
|
if (
|
||||||
|
!interaction.memberPermissions?.has(
|
||||||
|
PermissionsBitField.Flags.Administrator,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: 'You do not have permission to manually post facts.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await postFactOfTheDay(interaction.client);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
content: 'Fact of the day has been posted!',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default command;
|
361
src/commands/fun/giveaway.ts
Normal file
361
src/commands/fun/giveaway.ts
Normal file
|
@ -0,0 +1,361 @@
|
||||||
|
import {
|
||||||
|
SlashCommandBuilder,
|
||||||
|
PermissionsBitField,
|
||||||
|
EmbedBuilder,
|
||||||
|
ChatInputCommandInteraction,
|
||||||
|
} from 'discord.js';
|
||||||
|
|
||||||
|
import { SubcommandCommand } from '@/types/CommandTypes.js';
|
||||||
|
import {
|
||||||
|
getGiveaway,
|
||||||
|
getActiveGiveaways,
|
||||||
|
endGiveaway,
|
||||||
|
rerollGiveaway,
|
||||||
|
} from '@/db/db.js';
|
||||||
|
import {
|
||||||
|
createGiveawayEmbed,
|
||||||
|
formatWinnerMentions,
|
||||||
|
builder,
|
||||||
|
} from '@/util/giveaways/giveawayManager.js';
|
||||||
|
import { createPaginationButtons } from '@/util/helpers.js';
|
||||||
|
|
||||||
|
const command: SubcommandCommand = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('giveaway')
|
||||||
|
.setDescription('Create and manage giveaways')
|
||||||
|
.addSubcommand((sub) =>
|
||||||
|
sub.setName('create').setDescription('Start creating a new giveaway'),
|
||||||
|
)
|
||||||
|
.addSubcommand((sub) =>
|
||||||
|
sub.setName('list').setDescription('List all active giveaways'),
|
||||||
|
)
|
||||||
|
.addSubcommand((sub) =>
|
||||||
|
sub
|
||||||
|
.setName('end')
|
||||||
|
.setDescription('End a giveaway early')
|
||||||
|
.addStringOption((opt) =>
|
||||||
|
opt
|
||||||
|
.setName('id')
|
||||||
|
.setDescription('Id of the giveaway')
|
||||||
|
.setRequired(true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.addSubcommand((sub) =>
|
||||||
|
sub
|
||||||
|
.setName('reroll')
|
||||||
|
.setDescription('Reroll winners for a giveaway')
|
||||||
|
.addStringOption((opt) =>
|
||||||
|
opt
|
||||||
|
.setName('id')
|
||||||
|
.setDescription('Id of the giveaway')
|
||||||
|
.setRequired(true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
execute: async (interaction) => {
|
||||||
|
if (!interaction.isChatInputCommand()) return;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!interaction.memberPermissions?.has(
|
||||||
|
PermissionsBitField.Flags.ModerateMembers,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'You do not have permission to manage giveaways.',
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
|
||||||
|
switch (subcommand) {
|
||||||
|
case 'create':
|
||||||
|
await handleCreateGiveaway(interaction);
|
||||||
|
break;
|
||||||
|
case 'list':
|
||||||
|
await handleListGiveaways(interaction);
|
||||||
|
break;
|
||||||
|
case 'end':
|
||||||
|
await handleEndGiveaway(interaction);
|
||||||
|
break;
|
||||||
|
case 'reroll':
|
||||||
|
await handleRerollGiveaway(interaction);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the giveaway creation process
|
||||||
|
*/
|
||||||
|
async function handleCreateGiveaway(interaction: ChatInputCommandInteraction) {
|
||||||
|
await builder.startGiveawayBuilder(interaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the list giveaways subcommand
|
||||||
|
*/
|
||||||
|
async function handleListGiveaways(interaction: ChatInputCommandInteraction) {
|
||||||
|
await interaction.deferReply();
|
||||||
|
const GIVEAWAYS_PER_PAGE = 5;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const activeGiveaways = await getActiveGiveaways();
|
||||||
|
|
||||||
|
if (activeGiveaways.length === 0) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: 'There are no active giveaways at the moment.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages: EmbedBuilder[] = [];
|
||||||
|
for (let i = 0; i < activeGiveaways.length; i += GIVEAWAYS_PER_PAGE) {
|
||||||
|
const pageGiveaways = activeGiveaways.slice(i, i + GIVEAWAYS_PER_PAGE);
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle('🎉 Active Giveaways')
|
||||||
|
.setColor(0x00ff00)
|
||||||
|
.setDescription('Here are the currently active giveaways:')
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
pageGiveaways.forEach((giveaway) => {
|
||||||
|
embed.addFields({
|
||||||
|
name: `${giveaway.prize} (ID: ${giveaway.id})`,
|
||||||
|
value: [
|
||||||
|
`**Hosted by:** <@${giveaway.hostId}>`,
|
||||||
|
`**Winners:** ${giveaway.winnerCount}`,
|
||||||
|
`**Ends:** <t:${Math.floor(giveaway.endAt.getTime() / 1000)}:R>`,
|
||||||
|
`**Entries:** ${giveaway.participants?.length || 0}`,
|
||||||
|
`[Jump to Giveaway](https://discord.com/channels/${interaction.guildId}/${giveaway.channelId}/${giveaway.messageId})`,
|
||||||
|
].join('\n'),
|
||||||
|
inline: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
pages.push(embed);
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentPage = 0;
|
||||||
|
|
||||||
|
const message = await interaction.editReply({
|
||||||
|
embeds: [pages[currentPage]],
|
||||||
|
components: [createPaginationButtons(pages.length, currentPage)],
|
||||||
|
});
|
||||||
|
|
||||||
|
const collector = message.createMessageComponentCollector({
|
||||||
|
time: 300000,
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on('collect', async (i) => {
|
||||||
|
if (i.user.id !== interaction.user.id) {
|
||||||
|
await i.reply({
|
||||||
|
content: 'You cannot use these buttons.',
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i.isButton()) {
|
||||||
|
switch (i.customId) {
|
||||||
|
case 'first':
|
||||||
|
currentPage = 0;
|
||||||
|
break;
|
||||||
|
case 'prev':
|
||||||
|
if (currentPage > 0) currentPage--;
|
||||||
|
break;
|
||||||
|
case 'next':
|
||||||
|
if (currentPage < pages.length - 1) currentPage++;
|
||||||
|
break;
|
||||||
|
case 'last':
|
||||||
|
currentPage = pages.length - 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await i.update({
|
||||||
|
embeds: [pages[currentPage]],
|
||||||
|
components: [createPaginationButtons(pages.length, currentPage)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on('end', async () => {
|
||||||
|
try {
|
||||||
|
await interaction.editReply({
|
||||||
|
components: [],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing components:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching giveaways:', error);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: 'There was an error fetching the giveaways.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the end giveaway subcommand
|
||||||
|
*/
|
||||||
|
async function handleEndGiveaway(interaction: ChatInputCommandInteraction) {
|
||||||
|
await interaction.deferReply();
|
||||||
|
|
||||||
|
const id = interaction.options.getString('id', true);
|
||||||
|
const giveaway = await getGiveaway(id, true);
|
||||||
|
|
||||||
|
if (!giveaway) {
|
||||||
|
await interaction.editReply(`Giveaway with ID ${id} not found.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (giveaway.status !== 'active') {
|
||||||
|
await interaction.editReply('This giveaway has already ended.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endedGiveaway = await endGiveaway(id, true);
|
||||||
|
if (!endedGiveaway) {
|
||||||
|
await interaction.editReply(
|
||||||
|
'Failed to end the giveaway. Please try again.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const channel = interaction.guild?.channels.cache.get(giveaway.channelId);
|
||||||
|
if (!channel?.isTextBased()) {
|
||||||
|
await interaction.editReply(
|
||||||
|
'Giveaway channel not found or is not a text channel.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageId = giveaway.messageId;
|
||||||
|
const giveawayMessage = await channel.messages.fetch(messageId);
|
||||||
|
|
||||||
|
if (!giveawayMessage) {
|
||||||
|
await interaction.editReply('Giveaway message not found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await giveawayMessage.edit({
|
||||||
|
embeds: [
|
||||||
|
createGiveawayEmbed({
|
||||||
|
id: endedGiveaway.id,
|
||||||
|
prize: endedGiveaway.prize,
|
||||||
|
hostId: endedGiveaway.hostId,
|
||||||
|
winnersIds: endedGiveaway.winnersIds ?? [],
|
||||||
|
isEnded: true,
|
||||||
|
footerText: 'Ended early by a moderator',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
components: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (endedGiveaway.winnersIds?.length) {
|
||||||
|
const winnerMentions = formatWinnerMentions(endedGiveaway.winnersIds);
|
||||||
|
await channel.send({
|
||||||
|
content: `Congratulations ${winnerMentions}! You won **${endedGiveaway.prize}**!`,
|
||||||
|
allowedMentions: { users: endedGiveaway.winnersIds },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await channel.send(
|
||||||
|
`No one entered the giveaway for **${endedGiveaway.prize}**!`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.editReply('Giveaway ended successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error ending giveaway:', error);
|
||||||
|
await interaction.editReply('Failed to update the giveaway message.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the reroll giveaway subcommand
|
||||||
|
*/
|
||||||
|
async function handleRerollGiveaway(interaction: ChatInputCommandInteraction) {
|
||||||
|
await interaction.deferReply({ flags: ['Ephemeral'] });
|
||||||
|
const id = interaction.options.getString('id', true);
|
||||||
|
|
||||||
|
const originalGiveaway = await getGiveaway(id, true);
|
||||||
|
|
||||||
|
if (!originalGiveaway) {
|
||||||
|
await interaction.editReply(`Giveaway with ID ${id} not found.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originalGiveaway.status !== 'ended') {
|
||||||
|
await interaction.editReply(
|
||||||
|
'This giveaway is not yet ended. You can only reroll ended giveaways.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!originalGiveaway.participants?.length) {
|
||||||
|
await interaction.editReply(
|
||||||
|
'Cannot reroll because no one entered this giveaway.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rerolledGiveaway = await rerollGiveaway(id);
|
||||||
|
|
||||||
|
if (!rerolledGiveaway) {
|
||||||
|
await interaction.editReply(
|
||||||
|
'Failed to reroll the giveaway. An internal error occurred.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousWinners = originalGiveaway.winnersIds ?? [];
|
||||||
|
const newWinners = rerolledGiveaway.winnersIds ?? [];
|
||||||
|
|
||||||
|
const winnersChanged = !(
|
||||||
|
previousWinners.length === newWinners.length &&
|
||||||
|
previousWinners.every((w) => newWinners.includes(w))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!winnersChanged && newWinners.length > 0) {
|
||||||
|
await interaction.editReply(
|
||||||
|
'Could not reroll: No other eligible participants found besides the previous winner(s).',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newWinners.length === 0) {
|
||||||
|
await interaction.editReply(
|
||||||
|
'Could not reroll: No eligible participants found.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const channel = interaction.guild?.channels.cache.get(
|
||||||
|
rerolledGiveaway.channelId,
|
||||||
|
);
|
||||||
|
if (!channel?.isTextBased()) {
|
||||||
|
await interaction.editReply(
|
||||||
|
'Giveaway channel not found or is not a text channel. Reroll successful but announcement failed.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const winnerMentions = formatWinnerMentions(newWinners);
|
||||||
|
await channel.send({
|
||||||
|
content: `🎉 The giveaway for **${rerolledGiveaway.prize}** has been rerolled! New winner(s): ${winnerMentions}`,
|
||||||
|
allowedMentions: { users: newWinners },
|
||||||
|
});
|
||||||
|
|
||||||
|
await interaction.editReply('Giveaway rerolled successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error announcing rerolled giveaway:', error);
|
||||||
|
await interaction.editReply(
|
||||||
|
'Giveaway rerolled, but failed to announce the new winners.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default command;
|
168
src/commands/fun/leaderboard.ts
Normal file
168
src/commands/fun/leaderboard.ts
Normal file
|
@ -0,0 +1,168 @@
|
||||||
|
import {
|
||||||
|
SlashCommandBuilder,
|
||||||
|
EmbedBuilder,
|
||||||
|
ActionRowBuilder,
|
||||||
|
StringSelectMenuBuilder,
|
||||||
|
APIEmbed,
|
||||||
|
JSONEncodable,
|
||||||
|
} from 'discord.js';
|
||||||
|
|
||||||
|
import { OptionsCommand } from '@/types/CommandTypes.js';
|
||||||
|
import { getLevelLeaderboard } from '@/db/db.js';
|
||||||
|
import { createPaginationButtons } from '@/util/helpers.js';
|
||||||
|
|
||||||
|
const command: OptionsCommand = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('leaderboard')
|
||||||
|
.setDescription('Shows the server XP leaderboard')
|
||||||
|
.addIntegerOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('limit')
|
||||||
|
.setDescription('Number of users per page (default: 10)')
|
||||||
|
.setRequired(false),
|
||||||
|
),
|
||||||
|
execute: async (interaction) => {
|
||||||
|
if (!interaction.guild) return;
|
||||||
|
|
||||||
|
await interaction.deferReply();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const usersPerPage =
|
||||||
|
(interaction.options.get('limit')?.value as number) || 10;
|
||||||
|
|
||||||
|
const allUsers = await getLevelLeaderboard(100);
|
||||||
|
|
||||||
|
if (allUsers.length === 0) {
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle('🏆 Server Leaderboard')
|
||||||
|
.setColor(0x5865f2)
|
||||||
|
.setDescription('No users found on the leaderboard yet.')
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages: (APIEmbed | JSONEncodable<APIEmbed>)[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < allUsers.length; i += usersPerPage) {
|
||||||
|
const pageUsers = allUsers.slice(i, i + usersPerPage);
|
||||||
|
let leaderboardText = '';
|
||||||
|
|
||||||
|
for (let j = 0; j < pageUsers.length; j++) {
|
||||||
|
const user = pageUsers[j];
|
||||||
|
const position = i + j + 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const member = await interaction.guild.members.fetch(
|
||||||
|
user.discordId,
|
||||||
|
);
|
||||||
|
leaderboardText += `**${position}.** ${member} - Level ${user.level} (${user.xp} XP)\n`;
|
||||||
|
} catch (error) {
|
||||||
|
leaderboardText += `**${position}.** <@${user.discordId}> - Level ${user.level} (${user.xp} XP)\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle('🏆 Server Leaderboard')
|
||||||
|
.setColor(0x5865f2)
|
||||||
|
.setDescription(leaderboardText)
|
||||||
|
.setTimestamp()
|
||||||
|
.setFooter({
|
||||||
|
text: `Page ${Math.floor(i / usersPerPage) + 1} of ${Math.ceil(allUsers.length / usersPerPage)}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
pages.push(embed);
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentPage = 0;
|
||||||
|
|
||||||
|
const getButtonActionRow = () =>
|
||||||
|
createPaginationButtons(pages.length, currentPage);
|
||||||
|
|
||||||
|
const getSelectMenuRow = () => {
|
||||||
|
const options = pages.map((_, index) => ({
|
||||||
|
label: `Page ${index + 1}`,
|
||||||
|
value: index.toString(),
|
||||||
|
default: index === currentPage,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const select = new StringSelectMenuBuilder()
|
||||||
|
.setCustomId('select_page')
|
||||||
|
.setPlaceholder('Jump to a page')
|
||||||
|
.addOptions(options);
|
||||||
|
|
||||||
|
return new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
||||||
|
select,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const components =
|
||||||
|
pages.length > 1 ? [getButtonActionRow(), getSelectMenuRow()] : [];
|
||||||
|
|
||||||
|
const message = await interaction.editReply({
|
||||||
|
embeds: [pages[currentPage]],
|
||||||
|
components,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pages.length <= 1) return;
|
||||||
|
|
||||||
|
const collector = message.createMessageComponentCollector({
|
||||||
|
time: 300000,
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on('collect', async (i) => {
|
||||||
|
if (i.user.id !== interaction.user.id) {
|
||||||
|
await i.reply({
|
||||||
|
content: 'These controls are not for you!',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i.isButton()) {
|
||||||
|
switch (i.customId) {
|
||||||
|
case 'first':
|
||||||
|
currentPage = 0;
|
||||||
|
break;
|
||||||
|
case 'prev':
|
||||||
|
if (currentPage > 0) currentPage--;
|
||||||
|
break;
|
||||||
|
case 'next':
|
||||||
|
if (currentPage < pages.length - 1) currentPage++;
|
||||||
|
break;
|
||||||
|
case 'last':
|
||||||
|
currentPage = pages.length - 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i.isStringSelectMenu()) {
|
||||||
|
const selected = parseInt(i.values[0]);
|
||||||
|
if (!isNaN(selected) && selected >= 0 && selected < pages.length) {
|
||||||
|
currentPage = selected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await i.update({
|
||||||
|
embeds: [pages[currentPage]],
|
||||||
|
components: [getButtonActionRow(), getSelectMenuRow()],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on('end', async () => {
|
||||||
|
if (message) {
|
||||||
|
try {
|
||||||
|
await interaction.editReply({ components: [] });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing components:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting leaderboard:', error);
|
||||||
|
await interaction.editReply('Failed to get leaderboard information.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default command;
|
46
src/commands/fun/rank.ts
Normal file
46
src/commands/fun/rank.ts
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import { SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
|
import { OptionsCommand } from '@/types/CommandTypes.js';
|
||||||
|
import { generateRankCard, getXpToNextLevel } from '@/util/levelingSystem.js';
|
||||||
|
import { getUserLevel } from '@/db/db.js';
|
||||||
|
|
||||||
|
const command: OptionsCommand = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('rank')
|
||||||
|
.setDescription('Shows your current rank and level')
|
||||||
|
.addUserOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('user')
|
||||||
|
.setDescription('The user to check rank for (defaults to yourself)')
|
||||||
|
.setRequired(false),
|
||||||
|
),
|
||||||
|
execute: async (interaction) => {
|
||||||
|
const member = await interaction.guild?.members.fetch(
|
||||||
|
(interaction.options.get('user')?.value as string) || interaction.user.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
await interaction.reply('User not found in this server.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.deferReply();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userData = await getUserLevel(member.id);
|
||||||
|
const rankCard = await generateRankCard(member, userData);
|
||||||
|
|
||||||
|
const xpToNextLevel = getXpToNextLevel(userData.level, userData.xp);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `${member}'s rank - Level ${userData.level} (${userData.xp} XP, ${xpToNextLevel} XP until next level)`,
|
||||||
|
files: [rankCard],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting rank:', error);
|
||||||
|
await interaction.editReply('Failed to get rank information.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default command;
|
|
@ -1,9 +1,9 @@
|
||||||
import { PermissionsBitField, SlashCommandBuilder } from 'discord.js';
|
import { PermissionsBitField, SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
import { updateMember, updateMemberModerationHistory } from '../../db/db.js';
|
import { updateMember, updateMemberModerationHistory } from '@/db/db.js';
|
||||||
import { parseDuration, scheduleUnban } from '../../util/helpers.js';
|
import { parseDuration, scheduleUnban } from '@/util/helpers.js';
|
||||||
import { OptionsCommand } from '../../types/CommandTypes.js';
|
import { OptionsCommand } from '@/types/CommandTypes.js';
|
||||||
import logAction from '../../util/logging/logAction.js';
|
import logAction from '@/util/logging/logAction.js';
|
||||||
|
|
||||||
const command: OptionsCommand = {
|
const command: OptionsCommand = {
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { PermissionsBitField, SlashCommandBuilder } from 'discord.js';
|
import { PermissionsBitField, SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
import { executeUnban } from '../../util/helpers.js';
|
import { executeUnban } from '@/util/helpers.js';
|
||||||
import { OptionsCommand } from '../../types/CommandTypes.js';
|
import { OptionsCommand } from '@/types/CommandTypes.js';
|
||||||
|
|
||||||
const command: OptionsCommand = {
|
const command: OptionsCommand = {
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { PermissionsBitField, SlashCommandBuilder } from 'discord.js';
|
import { PermissionsBitField, SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
import { updateMemberModerationHistory } from '../../db/db.js';
|
import { updateMemberModerationHistory } from '@/db/db.js';
|
||||||
import { OptionsCommand } from '../../types/CommandTypes.js';
|
import { OptionsCommand } from '@/types/CommandTypes.js';
|
||||||
import logAction from '../../util/logging/logAction.js';
|
import logAction from '@/util/logging/logAction.js';
|
||||||
|
|
||||||
const command: OptionsCommand = {
|
const command: OptionsCommand = {
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { PermissionsBitField, SlashCommandBuilder } from 'discord.js';
|
import { PermissionsBitField, SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
import { Command } from '../../types/CommandTypes.js';
|
import { Command } from '@/types/CommandTypes.js';
|
||||||
|
|
||||||
const command: Command = {
|
const command: Command = {
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { PermissionsBitField, SlashCommandBuilder } from 'discord.js';
|
import { PermissionsBitField, SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
import { updateMember } from '../../db/db.js';
|
import { updateMember } from '@/db/db.js';
|
||||||
import { Command } from '../../types/CommandTypes.js';
|
import { Command } from '@/types/CommandTypes.js';
|
||||||
|
|
||||||
const command: Command = {
|
const command: Command = {
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
|
|
|
@ -1,16 +1,15 @@
|
||||||
import {
|
import {
|
||||||
SlashCommandBuilder,
|
SlashCommandBuilder,
|
||||||
EmbedBuilder,
|
EmbedBuilder,
|
||||||
ButtonBuilder,
|
|
||||||
ActionRowBuilder,
|
ActionRowBuilder,
|
||||||
ButtonStyle,
|
|
||||||
StringSelectMenuBuilder,
|
StringSelectMenuBuilder,
|
||||||
APIEmbed,
|
APIEmbed,
|
||||||
JSONEncodable,
|
JSONEncodable,
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
|
|
||||||
import { getAllMembers } from '../../db/db.js';
|
import { getAllMembers } from '@/db/db.js';
|
||||||
import { Command } from '../../types/CommandTypes.js';
|
import { Command } from '@/types/CommandTypes.js';
|
||||||
|
import { createPaginationButtons } from '@/util/helpers.js';
|
||||||
|
|
||||||
const command: Command = {
|
const command: Command = {
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
|
@ -19,7 +18,7 @@ const command: Command = {
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
let members = await getAllMembers();
|
let members = await getAllMembers();
|
||||||
members = members.sort((a, b) =>
|
members = members.sort((a, b) =>
|
||||||
a.discordUsername.localeCompare(b.discordUsername),
|
(a.discordUsername ?? '').localeCompare(b.discordUsername ?? ''),
|
||||||
);
|
);
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 15;
|
const ITEMS_PER_PAGE = 15;
|
||||||
|
@ -42,18 +41,7 @@ const command: Command = {
|
||||||
|
|
||||||
let currentPage = 0;
|
let currentPage = 0;
|
||||||
const getButtonActionRow = () =>
|
const getButtonActionRow = () =>
|
||||||
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
createPaginationButtons(pages.length, currentPage);
|
||||||
new ButtonBuilder()
|
|
||||||
.setCustomId('previous')
|
|
||||||
.setLabel('Previous')
|
|
||||||
.setStyle(ButtonStyle.Primary)
|
|
||||||
.setDisabled(currentPage === 0),
|
|
||||||
new ButtonBuilder()
|
|
||||||
.setCustomId('next')
|
|
||||||
.setLabel('Next')
|
|
||||||
.setStyle(ButtonStyle.Primary)
|
|
||||||
.setDisabled(currentPage === pages.length - 1),
|
|
||||||
);
|
|
||||||
|
|
||||||
const getSelectMenuRow = () => {
|
const getSelectMenuRow = () => {
|
||||||
const options = pages.map((_, index) => ({
|
const options = pages.map((_, index) => ({
|
||||||
|
@ -85,7 +73,7 @@ const command: Command = {
|
||||||
if (pages.length <= 1) return;
|
if (pages.length <= 1) return;
|
||||||
|
|
||||||
const collector = message.createMessageComponentCollector({
|
const collector = message.createMessageComponentCollector({
|
||||||
time: 60000,
|
time: 300000,
|
||||||
});
|
});
|
||||||
|
|
||||||
collector.on('collect', async (i) => {
|
collector.on('collect', async (i) => {
|
||||||
|
@ -98,10 +86,19 @@ const command: Command = {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (i.isButton()) {
|
if (i.isButton()) {
|
||||||
if (i.customId === 'previous' && currentPage > 0) {
|
switch (i.customId) {
|
||||||
currentPage--;
|
case 'first':
|
||||||
} else if (i.customId === 'next' && currentPage < pages.length - 1) {
|
currentPage = 0;
|
||||||
currentPage++;
|
break;
|
||||||
|
case 'prev':
|
||||||
|
if (currentPage > 0) currentPage--;
|
||||||
|
break;
|
||||||
|
case 'next':
|
||||||
|
if (currentPage < pages.length - 1) currentPage++;
|
||||||
|
break;
|
||||||
|
case 'last':
|
||||||
|
currentPage = pages.length - 1;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { SlashCommandBuilder } from 'discord.js';
|
import { SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
import { Command } from '../../types/CommandTypes.js';
|
import { Command } from '@/types/CommandTypes.js';
|
||||||
|
|
||||||
const command: Command = {
|
const command: Command = {
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
|
|
36
src/commands/util/recalculatelevels.ts
Normal file
36
src/commands/util/recalculatelevels.ts
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
import { PermissionsBitField, SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
|
import { Command } from '@/types/CommandTypes.js';
|
||||||
|
import { recalculateUserLevels } from '@/util/levelingSystem.js';
|
||||||
|
|
||||||
|
const command: Command = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('recalculatelevels')
|
||||||
|
.setDescription('(Admin Only) Recalculate all user levels'),
|
||||||
|
execute: async (interaction) => {
|
||||||
|
if (
|
||||||
|
!interaction.memberPermissions?.has(
|
||||||
|
PermissionsBitField.Flags.Administrator,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'You do not have permission to use this command.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.deferReply();
|
||||||
|
await interaction.editReply('Recalculating levels...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await recalculateUserLevels();
|
||||||
|
await interaction.editReply('Levels recalculated successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error recalculating levels:', error);
|
||||||
|
await interaction.editReply('Failed to recalculate levels.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default command;
|
200
src/commands/util/reconnect.ts
Normal file
200
src/commands/util/reconnect.ts
Normal file
|
@ -0,0 +1,200 @@
|
||||||
|
import {
|
||||||
|
CommandInteraction,
|
||||||
|
PermissionsBitField,
|
||||||
|
SlashCommandBuilder,
|
||||||
|
} from 'discord.js';
|
||||||
|
|
||||||
|
import { SubcommandCommand } from '@/types/CommandTypes.js';
|
||||||
|
import { loadConfig } from '@/util/configLoader.js';
|
||||||
|
import { initializeDatabaseConnection, ensureDbInitialized } from '@/db/db.js';
|
||||||
|
import { isRedisConnected } from '@/db/redis.js';
|
||||||
|
import {
|
||||||
|
NotificationType,
|
||||||
|
notifyManagers,
|
||||||
|
} from '@/util/notificationHandler.js';
|
||||||
|
|
||||||
|
const command: SubcommandCommand = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('reconnect')
|
||||||
|
.setDescription('(Manager Only) Force reconnection to database or Redis')
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('database')
|
||||||
|
.setDescription('(Manager Only) Force reconnection to the database'),
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('redis')
|
||||||
|
.setDescription('(Manager Only) Force reconnection to Redis cache'),
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('status')
|
||||||
|
.setDescription(
|
||||||
|
'(Manager Only) Check connection status of database and Redis',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
execute: async (interaction) => {
|
||||||
|
if (!interaction.isChatInputCommand()) return;
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
const managerRoleId = config.roles.staffRoles.find(
|
||||||
|
(role) => role.name === 'Manager',
|
||||||
|
)?.roleId;
|
||||||
|
|
||||||
|
const member = await interaction.guild?.members.fetch(interaction.user.id);
|
||||||
|
const hasManagerRole = member?.roles.cache.has(managerRoleId || '');
|
||||||
|
|
||||||
|
if (
|
||||||
|
!hasManagerRole &&
|
||||||
|
!interaction.memberPermissions?.has(
|
||||||
|
PermissionsBitField.Flags.Administrator,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await interaction.reply({
|
||||||
|
content:
|
||||||
|
'You do not have permission to use this command. This command is restricted to users with the Manager role.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
|
||||||
|
await interaction.deferReply({ flags: ['Ephemeral'] });
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (subcommand === 'database') {
|
||||||
|
await handleDatabaseReconnect(interaction);
|
||||||
|
} else if (subcommand === 'redis') {
|
||||||
|
await handleRedisReconnect(interaction);
|
||||||
|
} else if (subcommand === 'status') {
|
||||||
|
await handleStatusCheck(interaction);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error in reconnect command (${subcommand}):`, error);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `An error occurred while processing the reconnect command: \`${error}\``,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle database reconnection
|
||||||
|
*/
|
||||||
|
async function handleDatabaseReconnect(interaction: CommandInteraction) {
|
||||||
|
await interaction.editReply('Attempting to reconnect to the database...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const success = await initializeDatabaseConnection();
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
await interaction.editReply(
|
||||||
|
'✅ **Database reconnection successful!** All database functions should now be operational.',
|
||||||
|
);
|
||||||
|
|
||||||
|
notifyManagers(
|
||||||
|
interaction.client,
|
||||||
|
NotificationType.DATABASE_CONNECTION_RESTORED,
|
||||||
|
`Database connection manually restored by ${interaction.user.tag}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await interaction.editReply(
|
||||||
|
'❌ **Database reconnection failed.** Check the logs for more details.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reconnecting to database:', error);
|
||||||
|
await interaction.editReply(
|
||||||
|
`❌ **Database reconnection failed with error:** \`${error}\``,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle Redis reconnection
|
||||||
|
*/
|
||||||
|
async function handleRedisReconnect(interaction: CommandInteraction) {
|
||||||
|
await interaction.editReply('Attempting to reconnect to Redis...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const redisModule = await import('@/db/redis.js');
|
||||||
|
|
||||||
|
await redisModule.ensureRedisConnection();
|
||||||
|
|
||||||
|
const isConnected = redisModule.isRedisConnected();
|
||||||
|
|
||||||
|
if (isConnected) {
|
||||||
|
await interaction.editReply(
|
||||||
|
'✅ **Redis reconnection successful!** Cache functionality is now available.',
|
||||||
|
);
|
||||||
|
|
||||||
|
notifyManagers(
|
||||||
|
interaction.client,
|
||||||
|
NotificationType.REDIS_CONNECTION_RESTORED,
|
||||||
|
`Redis connection manually restored by ${interaction.user.tag}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await interaction.editReply(
|
||||||
|
'❌ **Redis reconnection failed.** The bot will continue to function without caching capabilities.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reconnecting to Redis:', error);
|
||||||
|
await interaction.editReply(
|
||||||
|
`❌ **Redis reconnection failed with error:** \`${error}\``,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle status check for both services
|
||||||
|
*/
|
||||||
|
async function handleStatusCheck(interaction: any) {
|
||||||
|
await interaction.editReply('Checking connection status...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dbStatus = await (async () => {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const redisStatus = isRedisConnected();
|
||||||
|
|
||||||
|
const statusEmbed = {
|
||||||
|
title: '🔌 Service Connection Status',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'Database',
|
||||||
|
value: dbStatus ? '✅ Connected' : '❌ Disconnected',
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Redis Cache',
|
||||||
|
value: redisStatus
|
||||||
|
? '✅ Connected'
|
||||||
|
: '⚠️ Disconnected (caching disabled)',
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
color:
|
||||||
|
dbStatus && redisStatus ? 0x00ff00 : dbStatus ? 0xffaa00 : 0xff0000,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await interaction.editReply({ content: '', embeds: [statusEmbed] });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking connection status:', error);
|
||||||
|
await interaction.editReply(
|
||||||
|
`❌ **Error checking connection status:** \`${error}\``,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default command;
|
93
src/commands/util/restart.ts
Normal file
93
src/commands/util/restart.ts
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
import { PermissionsBitField, SlashCommandBuilder } from 'discord.js';
|
||||||
|
import { exec } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
|
||||||
|
import { Command } from '@/types/CommandTypes.js';
|
||||||
|
import { loadConfig } from '@/util/configLoader.js';
|
||||||
|
import {
|
||||||
|
NotificationType,
|
||||||
|
notifyManagers,
|
||||||
|
} from '@/util/notificationHandler.js';
|
||||||
|
import { isRedisConnected } from '@/db/redis.js';
|
||||||
|
import { ensureDatabaseConnection } from '@/db/db.js';
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
const command: Command = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('restart')
|
||||||
|
.setDescription('(Manager Only) Restart the bot'),
|
||||||
|
execute: async (interaction) => {
|
||||||
|
const config = loadConfig();
|
||||||
|
const managerRoleId = config.roles.staffRoles.find(
|
||||||
|
(role) => role.name === 'Manager',
|
||||||
|
)?.roleId;
|
||||||
|
|
||||||
|
const member = await interaction.guild?.members.fetch(interaction.user.id);
|
||||||
|
const hasManagerRole = member?.roles.cache.has(managerRoleId || '');
|
||||||
|
|
||||||
|
if (
|
||||||
|
!hasManagerRole &&
|
||||||
|
!interaction.memberPermissions?.has(
|
||||||
|
PermissionsBitField.Flags.Administrator,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await interaction.reply({
|
||||||
|
content:
|
||||||
|
'You do not have permission to restart the bot. This command is restricted to users with the Manager role.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'Restarting the bot... This may take a few moments.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const dbConnected = await ensureDatabaseConnection();
|
||||||
|
const redisConnected = isRedisConnected();
|
||||||
|
let statusInfo = '';
|
||||||
|
|
||||||
|
if (!dbConnected) {
|
||||||
|
statusInfo += '⚠️ Database is currently disconnected\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!redisConnected) {
|
||||||
|
statusInfo += '⚠️ Redis caching is currently unavailable\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dbConnected && redisConnected) {
|
||||||
|
statusInfo = '✅ All services are operational\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
await notifyManagers(
|
||||||
|
interaction.client,
|
||||||
|
NotificationType.BOT_RESTARTING,
|
||||||
|
`Restart initiated by ${interaction.user.tag}\n\nCurrent service status:\n${statusInfo}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
console.log(
|
||||||
|
`Bot restart initiated by ${interaction.user.tag} (${interaction.user.id})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await execAsync('yarn restart');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to restart the bot:', error);
|
||||||
|
try {
|
||||||
|
await interaction.followUp({
|
||||||
|
content:
|
||||||
|
'Failed to restart the bot. Check the console for details.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// If this fails too, we can't do much
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default command;
|
|
@ -1,6 +1,6 @@
|
||||||
import { SlashCommandBuilder, EmbedBuilder } from 'discord.js';
|
import { SlashCommandBuilder, EmbedBuilder } from 'discord.js';
|
||||||
|
|
||||||
import { Command } from '../../types/CommandTypes.js';
|
import { Command } from '@/types/CommandTypes.js';
|
||||||
|
|
||||||
const rulesEmbed = new EmbedBuilder()
|
const rulesEmbed = new EmbedBuilder()
|
||||||
.setColor(0x0099ff)
|
.setColor(0x0099ff)
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { SlashCommandBuilder } from 'discord.js';
|
import { SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
import { Command } from '../../types/CommandTypes.js';
|
import { Command } from '@/types/CommandTypes.js';
|
||||||
|
|
||||||
const command: Command = {
|
const command: Command = {
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
|
|
|
@ -5,8 +5,8 @@ import {
|
||||||
PermissionsBitField,
|
PermissionsBitField,
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
|
|
||||||
import { getMember } from '../../db/db.js';
|
import { getMember } from '@/db/db.js';
|
||||||
import { OptionsCommand } from '../../types/CommandTypes.js';
|
import { OptionsCommand } from '@/types/CommandTypes.js';
|
||||||
|
|
||||||
const command: OptionsCommand = {
|
const command: OptionsCommand = {
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
|
|
132
src/commands/util/xp.ts
Normal file
132
src/commands/util/xp.ts
Normal file
|
@ -0,0 +1,132 @@
|
||||||
|
import { SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
|
import { SubcommandCommand } from '@/types/CommandTypes.js';
|
||||||
|
import { addXpToUser, getUserLevel } from '@/db/db.js';
|
||||||
|
import { loadConfig } from '@/util/configLoader.js';
|
||||||
|
|
||||||
|
const command: SubcommandCommand = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('xp')
|
||||||
|
.setDescription('(Manager only) Manage user XP')
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('add')
|
||||||
|
.setDescription('Add XP to a member')
|
||||||
|
.addUserOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('user')
|
||||||
|
.setDescription('The user to add XP to')
|
||||||
|
.setRequired(true),
|
||||||
|
)
|
||||||
|
.addIntegerOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('amount')
|
||||||
|
.setDescription('The amount of XP to add')
|
||||||
|
.setRequired(true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('remove')
|
||||||
|
.setDescription('Remove XP from a member')
|
||||||
|
.addUserOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('user')
|
||||||
|
.setDescription('The user to remove XP from')
|
||||||
|
.setRequired(true),
|
||||||
|
)
|
||||||
|
.addIntegerOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('amount')
|
||||||
|
.setDescription('The amount of XP to remove')
|
||||||
|
.setRequired(true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('set')
|
||||||
|
.setDescription('Set XP for a member')
|
||||||
|
.addUserOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('user')
|
||||||
|
.setDescription('The user to set XP for')
|
||||||
|
.setRequired(true),
|
||||||
|
)
|
||||||
|
.addIntegerOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('amount')
|
||||||
|
.setDescription('The amount of XP to set')
|
||||||
|
.setRequired(true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('reset')
|
||||||
|
.setDescription('Reset XP for a member')
|
||||||
|
.addUserOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('user')
|
||||||
|
.setDescription('The user to reset XP for')
|
||||||
|
.setRequired(true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
execute: async (interaction) => {
|
||||||
|
if (!interaction.isChatInputCommand()) return;
|
||||||
|
|
||||||
|
const commandUser = interaction.guild?.members.cache.get(
|
||||||
|
interaction.user.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
const managerRoleId = config.roles.staffRoles.find(
|
||||||
|
(role) => role.name === 'Manager',
|
||||||
|
)?.roleId;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!commandUser ||
|
||||||
|
!managerRoleId ||
|
||||||
|
commandUser.roles.highest.comparePositionTo(managerRoleId) < 0
|
||||||
|
) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'You do not have permission to use this command',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.deferReply({
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
await interaction.editReply('Processing...');
|
||||||
|
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
const user = interaction.options.getUser('user', true);
|
||||||
|
const amount = interaction.options.getInteger('amount', false);
|
||||||
|
|
||||||
|
const userData = await getUserLevel(user.id);
|
||||||
|
|
||||||
|
if (subcommand === 'add') {
|
||||||
|
await addXpToUser(user.id, amount!);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Added ${amount} XP to <@${user.id}>`,
|
||||||
|
});
|
||||||
|
} else if (subcommand === 'remove') {
|
||||||
|
await addXpToUser(user.id, -amount!);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Removed ${amount} XP from <@${user.id}>`,
|
||||||
|
});
|
||||||
|
} else if (subcommand === 'set') {
|
||||||
|
await addXpToUser(user.id, amount! - userData.xp);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Set ${amount} XP for <@${user.id}>`,
|
||||||
|
});
|
||||||
|
} else if (subcommand === 'reset') {
|
||||||
|
await addXpToUser(user.id, userData.xp * -1);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Reset XP for <@${user.id}>`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default command;
|
447
src/db/db.ts
447
src/db/db.ts
|
@ -1,20 +1,45 @@
|
||||||
|
// ========================
|
||||||
|
// External Imports
|
||||||
|
// ========================
|
||||||
import pkg from 'pg';
|
import pkg from 'pg';
|
||||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||||
import { eq } from 'drizzle-orm';
|
import { Client } from 'discord.js';
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// Internal Imports
|
||||||
|
// ========================
|
||||||
import * as schema from './schema.js';
|
import * as schema from './schema.js';
|
||||||
import { loadConfig } from '../util/configLoader.js';
|
import { loadConfig } from '@/util/configLoader.js';
|
||||||
import { del, exists, getJson, setJson } from './redis.js';
|
import { del, exists, getJson, setJson } from './redis.js';
|
||||||
|
import {
|
||||||
|
logManagerNotification,
|
||||||
|
NotificationType,
|
||||||
|
notifyManagers,
|
||||||
|
} from '@/util/notificationHandler.js';
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// Database Configuration
|
||||||
|
// ========================
|
||||||
const { Pool } = pkg;
|
const { Pool } = pkg;
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
|
|
||||||
const dbPool = new Pool({
|
// Connection parameters
|
||||||
connectionString: config.dbConnectionString,
|
const MAX_DB_RETRY_ATTEMPTS = config.database.maxRetryAttempts;
|
||||||
ssl: true,
|
const INITIAL_DB_RETRY_DELAY = config.database.retryDelay;
|
||||||
});
|
|
||||||
export const db = drizzle({ client: dbPool, schema });
|
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// Connection State Variables
|
||||||
|
// ========================
|
||||||
|
let isDbConnected = false;
|
||||||
|
let connectionAttempts = 0;
|
||||||
|
let hasNotifiedDbDisconnect = false;
|
||||||
|
let discordClient: Client | null = null;
|
||||||
|
let dbPool: pkg.Pool;
|
||||||
|
export let db: ReturnType<typeof drizzle>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom error class for database operations
|
||||||
|
*/
|
||||||
class DatabaseError extends Error {
|
class DatabaseError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
|
@ -25,208 +50,264 @@ class DatabaseError extends Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAllMembers() {
|
// ========================
|
||||||
|
// Client Management
|
||||||
|
// ========================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the Discord client for sending notifications
|
||||||
|
* @param client - The Discord client
|
||||||
|
*/
|
||||||
|
export function setDiscordClient(client: Client): void {
|
||||||
|
discordClient = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// Connection Management
|
||||||
|
// ========================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the database connection with retry logic
|
||||||
|
* @returns Promise resolving to true if connected successfully, false otherwise
|
||||||
|
*/
|
||||||
|
export async function initializeDatabaseConnection(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
if (await exists('nonBotMembers')) {
|
// Check if existing connection is working
|
||||||
const memberData =
|
if (dbPool) {
|
||||||
await getJson<(typeof schema.memberTable.$inferSelect)[]>(
|
try {
|
||||||
'nonBotMembers',
|
await dbPool.query('SELECT 1');
|
||||||
);
|
isDbConnected = true;
|
||||||
if (memberData && memberData.length > 0) {
|
return true;
|
||||||
return memberData;
|
|
||||||
} else {
|
|
||||||
await del('nonBotMembers');
|
|
||||||
return await getAllMembers();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const nonBotMembers = await db
|
|
||||||
.select()
|
|
||||||
.from(schema.memberTable)
|
|
||||||
.where(eq(schema.memberTable.currentlyInServer, true));
|
|
||||||
await setJson<(typeof schema.memberTable.$inferSelect)[]>(
|
|
||||||
'nonBotMembers',
|
|
||||||
nonBotMembers,
|
|
||||||
);
|
|
||||||
return nonBotMembers;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting all members: ', error);
|
console.warn(
|
||||||
throw new DatabaseError('Failed to get all members: ', error as Error);
|
'Existing database connection is not responsive, creating a new one',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await dbPool.end();
|
||||||
|
} catch (endError) {
|
||||||
|
console.error('Error ending pool:', endError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the database connection attempt
|
||||||
|
console.log(
|
||||||
|
`Connecting to database... (connectionString length: ${config.database.dbConnectionString.length})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create new connection pool
|
||||||
|
dbPool = new Pool({
|
||||||
|
connectionString: config.database.dbConnectionString,
|
||||||
|
ssl: true,
|
||||||
|
connectionTimeoutMillis: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test connection
|
||||||
|
await dbPool.query('SELECT 1');
|
||||||
|
|
||||||
|
// Initialize Drizzle ORM
|
||||||
|
db = drizzle({ client: dbPool, schema });
|
||||||
|
|
||||||
|
// Connection successful
|
||||||
|
console.info('Successfully connected to database');
|
||||||
|
isDbConnected = true;
|
||||||
|
connectionAttempts = 0;
|
||||||
|
|
||||||
|
// Send notification if connection was previously lost
|
||||||
|
if (hasNotifiedDbDisconnect && discordClient) {
|
||||||
|
logManagerNotification(NotificationType.DATABASE_CONNECTION_RESTORED);
|
||||||
|
notifyManagers(
|
||||||
|
discordClient,
|
||||||
|
NotificationType.DATABASE_CONNECTION_RESTORED,
|
||||||
|
);
|
||||||
|
hasNotifiedDbDisconnect = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to connect to database:', error);
|
||||||
|
isDbConnected = false;
|
||||||
|
connectionAttempts++;
|
||||||
|
|
||||||
|
// Handle max retry attempts exceeded
|
||||||
|
if (connectionAttempts >= MAX_DB_RETRY_ATTEMPTS) {
|
||||||
|
if (!hasNotifiedDbDisconnect && discordClient) {
|
||||||
|
const message = `Failed to connect to database after ${connectionAttempts} attempts.`;
|
||||||
|
console.error(message);
|
||||||
|
logManagerNotification(
|
||||||
|
NotificationType.DATABASE_CONNECTION_LOST,
|
||||||
|
`Error: ${error}`,
|
||||||
|
);
|
||||||
|
notifyManagers(
|
||||||
|
discordClient,
|
||||||
|
NotificationType.DATABASE_CONNECTION_LOST,
|
||||||
|
`Connection attempts exhausted after ${connectionAttempts} tries. The bot cannot function without database access and will now terminate.`,
|
||||||
|
);
|
||||||
|
hasNotifiedDbDisconnect = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminate after sending notifications
|
||||||
|
setTimeout(() => {
|
||||||
|
console.error('Database connection failed, shutting down bot');
|
||||||
|
process.exit(1);
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry connection with exponential backoff
|
||||||
|
const delay = Math.min(
|
||||||
|
INITIAL_DB_RETRY_DELAY * Math.pow(2, connectionAttempts - 1),
|
||||||
|
30000,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`Retrying database connection in ${delay}ms... (Attempt ${connectionAttempts}/${MAX_DB_RETRY_ATTEMPTS})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
setTimeout(initializeDatabaseConnection, delay);
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setMembers(nonBotMembers: any) {
|
// Initialize database connection
|
||||||
try {
|
let dbInitPromise = initializeDatabaseConnection().catch((error) => {
|
||||||
nonBotMembers.forEach(async (member: any) => {
|
console.error('Failed to initialize database connection:', error);
|
||||||
const memberInfo = await db
|
process.exit(1);
|
||||||
.select()
|
});
|
||||||
.from(schema.memberTable)
|
|
||||||
.where(eq(schema.memberTable.discordId, member.user.id));
|
// ========================
|
||||||
if (memberInfo.length > 0) {
|
// Helper Functions
|
||||||
await updateMember({
|
// ========================
|
||||||
discordId: member.user.id,
|
|
||||||
discordUsername: member.user.username,
|
/**
|
||||||
currentlyInServer: true,
|
* Ensures the database is initialized and returns a promise
|
||||||
});
|
* @returns Promise for database initialization
|
||||||
} else {
|
*/
|
||||||
const members: typeof schema.memberTable.$inferInsert = {
|
export async function ensureDbInitialized(): Promise<void> {
|
||||||
discordId: member.user.id,
|
await dbInitPromise;
|
||||||
discordUsername: member.user.username,
|
|
||||||
};
|
if (!isDbConnected) {
|
||||||
await db.insert(schema.memberTable).values(members);
|
dbInitPromise = initializeDatabaseConnection();
|
||||||
}
|
await dbInitPromise;
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error setting members: ', error);
|
|
||||||
throw new DatabaseError('Failed to set members: ', error as Error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMember(discordId: string) {
|
/**
|
||||||
try {
|
* Checks if the database connection is active and working
|
||||||
if (await exists(`${discordId}-memberInfo`)) {
|
* @returns Promise resolving to true if connected, false otherwise
|
||||||
const cachedMember = await getJson<
|
*/
|
||||||
typeof schema.memberTable.$inferSelect
|
export async function ensureDatabaseConnection(): Promise<boolean> {
|
||||||
>(`${discordId}-memberInfo`);
|
await ensureDbInitialized();
|
||||||
const cachedModerationHistory = await getJson<
|
|
||||||
(typeof schema.moderationTable.$inferSelect)[]
|
|
||||||
>(`${discordId}-moderationHistory`);
|
|
||||||
|
|
||||||
|
if (!isDbConnected) {
|
||||||
|
return await initializeDatabaseConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await dbPool.query('SELECT 1');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Database connection test failed:', error);
|
||||||
|
isDbConnected = false;
|
||||||
|
return await initializeDatabaseConnection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic error handler for database operations
|
||||||
|
* @param errorMessage - Error message to log
|
||||||
|
* @param error - Original error object
|
||||||
|
* @throws {DatabaseError} - Always throws a wrapped database error
|
||||||
|
*/
|
||||||
|
export const handleDbError = (errorMessage: string, error: Error): never => {
|
||||||
|
console.error(`${errorMessage}:`, error);
|
||||||
|
|
||||||
|
// Check if error is related to connection and attempt to reconnect
|
||||||
if (
|
if (
|
||||||
cachedMember &&
|
error.message.includes('connection') ||
|
||||||
'discordId' in cachedMember &&
|
error.message.includes('connect')
|
||||||
cachedModerationHistory &&
|
|
||||||
cachedModerationHistory.length > 0
|
|
||||||
) {
|
) {
|
||||||
return {
|
isDbConnected = false;
|
||||||
...cachedMember,
|
ensureDatabaseConnection().catch((err) => {
|
||||||
moderations: cachedModerationHistory,
|
console.error('Failed to reconnect to database:', err);
|
||||||
};
|
|
||||||
} else {
|
|
||||||
await del(`${discordId}-memberInfo`);
|
|
||||||
await del(`${discordId}-moderationHistory`);
|
|
||||||
return await getMember(discordId);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const member = await db.query.memberTable.findFirst({
|
|
||||||
where: eq(schema.memberTable.discordId, discordId),
|
|
||||||
with: {
|
|
||||||
moderations: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await setJson<typeof schema.memberTable.$inferSelect>(
|
|
||||||
`${discordId}-memberInfo`,
|
|
||||||
member!,
|
|
||||||
);
|
|
||||||
await setJson<(typeof schema.moderationTable.$inferSelect)[]>(
|
|
||||||
`${discordId}-moderationHistory`,
|
|
||||||
member!.moderations,
|
|
||||||
);
|
|
||||||
|
|
||||||
return member;
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('Error getting member: ', error);
|
|
||||||
throw new DatabaseError('Failed to get member: ', error as Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateMember({
|
throw new DatabaseError(errorMessage, error);
|
||||||
discordId,
|
};
|
||||||
discordUsername,
|
|
||||||
currentlyInServer,
|
// ========================
|
||||||
currentlyBanned,
|
// Cache Management
|
||||||
}: schema.memberTableTypes) {
|
// ========================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks and retrieves cached data or fetches from database
|
||||||
|
* @param cacheKey - Key to check in cache
|
||||||
|
* @param dbFetch - Function to fetch data from database
|
||||||
|
* @param ttl - Time to live for cache in seconds
|
||||||
|
* @returns Cached or freshly fetched data
|
||||||
|
*/
|
||||||
|
export async function withCache<T>(
|
||||||
|
cacheKey: string,
|
||||||
|
dbFetch: () => Promise<T>,
|
||||||
|
ttl?: number,
|
||||||
|
): Promise<T> {
|
||||||
try {
|
try {
|
||||||
const result = await db
|
const cachedData = await getJson<T>(cacheKey);
|
||||||
.update(schema.memberTable)
|
if (cachedData !== null) {
|
||||||
.set({
|
return cachedData;
|
||||||
discordUsername,
|
|
||||||
currentlyInServer,
|
|
||||||
currentlyBanned,
|
|
||||||
})
|
|
||||||
.where(eq(schema.memberTable.discordId, discordId));
|
|
||||||
|
|
||||||
if (await exists(`${discordId}-memberInfo`)) {
|
|
||||||
await del(`${discordId}-memberInfo`);
|
|
||||||
}
|
}
|
||||||
if (await exists('nonBotMembers')) {
|
|
||||||
await del('nonBotMembers');
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating member: ', error);
|
console.warn(
|
||||||
throw new DatabaseError('Failed to update member: ', error as Error);
|
`Cache retrieval failed for ${cacheKey}, falling back to database:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateMemberModerationHistory({
|
const data = await dbFetch();
|
||||||
discordId,
|
|
||||||
moderatorDiscordId,
|
|
||||||
action,
|
|
||||||
reason,
|
|
||||||
duration,
|
|
||||||
createdAt,
|
|
||||||
expiresAt,
|
|
||||||
active,
|
|
||||||
}: schema.moderationTableTypes) {
|
|
||||||
try {
|
try {
|
||||||
const moderationEntry = {
|
await setJson(cacheKey, data, ttl);
|
||||||
discordId,
|
|
||||||
moderatorDiscordId,
|
|
||||||
action,
|
|
||||||
reason,
|
|
||||||
duration,
|
|
||||||
createdAt,
|
|
||||||
expiresAt,
|
|
||||||
active,
|
|
||||||
};
|
|
||||||
const result = await db
|
|
||||||
.insert(schema.moderationTable)
|
|
||||||
.values(moderationEntry);
|
|
||||||
|
|
||||||
if (await exists(`${discordId}-moderationHistory`)) {
|
|
||||||
await del(`${discordId}-moderationHistory`);
|
|
||||||
}
|
|
||||||
if (await exists(`${discordId}-memberInfo`)) {
|
|
||||||
await del(`${discordId}-memberInfo`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating moderation history: ', error);
|
console.warn(`Failed to cache data for ${cacheKey}:`, error);
|
||||||
throw new DatabaseError(
|
|
||||||
'Failed to update moderation history: ',
|
|
||||||
error as Error,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMemberModerationHistory(discordId: string) {
|
/**
|
||||||
|
* Invalidates a cache key if it exists
|
||||||
|
* @param cacheKey - Key to invalidate
|
||||||
|
*/
|
||||||
|
export async function invalidateCache(cacheKey: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (await exists(`${discordId}-moderationHistory`)) {
|
if (await exists(cacheKey)) {
|
||||||
return await getJson<(typeof schema.moderationTable.$inferSelect)[]>(
|
await del(cacheKey);
|
||||||
`${discordId}-moderationHistory`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const moderationHistory = await db
|
|
||||||
.select()
|
|
||||||
.from(schema.moderationTable)
|
|
||||||
.where(eq(schema.moderationTable.discordId, discordId));
|
|
||||||
|
|
||||||
await setJson<(typeof schema.moderationTable.$inferSelect)[]>(
|
|
||||||
`${discordId}-moderationHistory`,
|
|
||||||
moderationHistory,
|
|
||||||
);
|
|
||||||
return moderationHistory;
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting moderation history: ', error);
|
console.warn(`Error invalidating cache for key ${cacheKey}:`, error);
|
||||||
throw new DatabaseError(
|
|
||||||
'Failed to get moderation history: ',
|
|
||||||
error as Error,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// Database Functions Exports
|
||||||
|
// ========================
|
||||||
|
|
||||||
|
// Achievement related functions
|
||||||
|
export * from './functions/achievementFunctions.js';
|
||||||
|
|
||||||
|
// Facts system functions
|
||||||
|
export * from './functions/factFunctions.js';
|
||||||
|
|
||||||
|
// Giveaway management functions
|
||||||
|
export * from './functions/giveawayFunctions.js';
|
||||||
|
|
||||||
|
// User leveling system functions
|
||||||
|
export * from './functions/levelFunctions.js';
|
||||||
|
|
||||||
|
// Guild member management functions
|
||||||
|
export * from './functions/memberFunctions.js';
|
||||||
|
|
||||||
|
// Moderation and administration functions
|
||||||
|
export * from './functions/moderationFunctions.js';
|
||||||
|
|
282
src/db/functions/achievementFunctions.ts
Normal file
282
src/db/functions/achievementFunctions.ts
Normal file
|
@ -0,0 +1,282 @@
|
||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db, ensureDbInitialized, handleDbError } from '../db.js';
|
||||||
|
import * as schema from '../schema.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all achievement definitions
|
||||||
|
* @returns Array of achievement definitions
|
||||||
|
*/
|
||||||
|
export async function getAllAchievements(): Promise<
|
||||||
|
schema.achievementDefinitionsTableTypes[]
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot get achievements');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return await db
|
||||||
|
.select()
|
||||||
|
.from(schema.achievementDefinitionsTable)
|
||||||
|
.orderBy(schema.achievementDefinitionsTable.threshold);
|
||||||
|
} catch (error) {
|
||||||
|
return handleDbError('Failed to get all achievements', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get achievements for a specific user
|
||||||
|
* @param userId - Discord ID of the user
|
||||||
|
* @returns Array of user achievements
|
||||||
|
*/
|
||||||
|
export async function getUserAchievements(
|
||||||
|
userId: string,
|
||||||
|
): Promise<schema.userAchievementsTableTypes[]> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot get user achievements');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return await db
|
||||||
|
.select({
|
||||||
|
id: schema.userAchievementsTable.id,
|
||||||
|
discordId: schema.userAchievementsTable.discordId,
|
||||||
|
achievementId: schema.userAchievementsTable.achievementId,
|
||||||
|
earnedAt: schema.userAchievementsTable.earnedAt,
|
||||||
|
progress: schema.userAchievementsTable.progress,
|
||||||
|
})
|
||||||
|
.from(schema.userAchievementsTable)
|
||||||
|
.where(eq(schema.userAchievementsTable.discordId, userId));
|
||||||
|
} catch (error) {
|
||||||
|
return handleDbError('Failed to get user achievements', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Award an achievement to a user
|
||||||
|
* @param userId - Discord ID of the user
|
||||||
|
* @param achievementId - ID of the achievement
|
||||||
|
* @returns Boolean indicating success
|
||||||
|
*/
|
||||||
|
export async function awardAchievement(
|
||||||
|
userId: string,
|
||||||
|
achievementId: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot award achievement');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.userAchievementsTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.userAchievementsTable.discordId, userId),
|
||||||
|
eq(schema.userAchievementsTable.achievementId, achievementId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.then((rows) => rows[0]);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (existing.earnedAt) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(schema.userAchievementsTable)
|
||||||
|
.set({
|
||||||
|
earnedAt: new Date(),
|
||||||
|
progress: 100,
|
||||||
|
})
|
||||||
|
.where(eq(schema.userAchievementsTable.id, existing.id));
|
||||||
|
} else {
|
||||||
|
await db.insert(schema.userAchievementsTable).values({
|
||||||
|
discordId: userId,
|
||||||
|
achievementId: achievementId,
|
||||||
|
earnedAt: new Date(),
|
||||||
|
progress: 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
handleDbError('Failed to award achievement', error as Error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update achievement progress for a user
|
||||||
|
* @param userId - Discord ID of the user
|
||||||
|
* @param achievementId - ID of the achievement
|
||||||
|
* @param progress - Progress value (0-100)
|
||||||
|
* @returns Boolean indicating success
|
||||||
|
*/
|
||||||
|
export async function updateAchievementProgress(
|
||||||
|
userId: string,
|
||||||
|
achievementId: number,
|
||||||
|
progress: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error(
|
||||||
|
'Database not initialized, cannot update achievement progress',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.userAchievementsTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.userAchievementsTable.discordId, userId),
|
||||||
|
eq(schema.userAchievementsTable.achievementId, achievementId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.then((rows) => rows[0]);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (existing.earnedAt) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(schema.userAchievementsTable)
|
||||||
|
.set({
|
||||||
|
progress: Math.floor(progress) > 100 ? 100 : Math.floor(progress),
|
||||||
|
})
|
||||||
|
.where(eq(schema.userAchievementsTable.id, existing.id));
|
||||||
|
} else {
|
||||||
|
await db.insert(schema.userAchievementsTable).values({
|
||||||
|
discordId: userId,
|
||||||
|
achievementId: achievementId,
|
||||||
|
progress: Math.floor(progress) > 100 ? 100 : Math.floor(progress),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
handleDbError('Failed to update achievement progress', error as Error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new achievement definition
|
||||||
|
* @param achievementData - Achievement definition data
|
||||||
|
* @returns Created achievement or undefined on failure
|
||||||
|
*/
|
||||||
|
export async function createAchievement(achievementData: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
requirementType: string;
|
||||||
|
threshold: number;
|
||||||
|
requirement?: any;
|
||||||
|
rewardType?: string;
|
||||||
|
rewardValue?: string;
|
||||||
|
}): Promise<schema.achievementDefinitionsTableTypes | undefined> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot create achievement');
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [achievement] = await db
|
||||||
|
.insert(schema.achievementDefinitionsTable)
|
||||||
|
.values({
|
||||||
|
name: achievementData.name,
|
||||||
|
description: achievementData.description,
|
||||||
|
imageUrl: achievementData.imageUrl || null,
|
||||||
|
requirementType: achievementData.requirementType,
|
||||||
|
threshold: achievementData.threshold,
|
||||||
|
requirement: achievementData.requirement || {},
|
||||||
|
rewardType: achievementData.rewardType || null,
|
||||||
|
rewardValue: achievementData.rewardValue || null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return achievement;
|
||||||
|
} catch (error) {
|
||||||
|
return handleDbError('Failed to create achievement', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an achievement definition
|
||||||
|
* @param achievementId - ID of the achievement to delete
|
||||||
|
* @returns Boolean indicating success
|
||||||
|
*/
|
||||||
|
export async function deleteAchievement(
|
||||||
|
achievementId: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot delete achievement');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(schema.userAchievementsTable)
|
||||||
|
.where(eq(schema.userAchievementsTable.achievementId, achievementId));
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(schema.achievementDefinitionsTable)
|
||||||
|
.where(eq(schema.achievementDefinitionsTable.id, achievementId));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
handleDbError('Failed to delete achievement', error as Error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes an achievement from a user
|
||||||
|
* @param discordId - Discord user ID
|
||||||
|
* @param achievementId - Achievement ID to remove
|
||||||
|
* @returns boolean indicating success
|
||||||
|
*/
|
||||||
|
export async function removeUserAchievement(
|
||||||
|
discordId: string,
|
||||||
|
achievementId: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot remove user achievement');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(schema.userAchievementsTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.userAchievementsTable.discordId, discordId),
|
||||||
|
eq(schema.userAchievementsTable.achievementId, achievementId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
handleDbError('Failed to remove user achievement', error as Error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
198
src/db/functions/factFunctions.ts
Normal file
198
src/db/functions/factFunctions.ts
Normal file
|
@ -0,0 +1,198 @@
|
||||||
|
import { and, eq, isNull, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import {
|
||||||
|
db,
|
||||||
|
ensureDbInitialized,
|
||||||
|
handleDbError,
|
||||||
|
invalidateCache,
|
||||||
|
withCache,
|
||||||
|
} from '../db.js';
|
||||||
|
import * as schema from '../schema.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new fact to the database
|
||||||
|
* @param content - Content of the fact
|
||||||
|
* @param source - Source of the fact
|
||||||
|
* @param addedBy - Discord ID of the user who added the fact
|
||||||
|
* @param approved - Whether the fact is approved or not
|
||||||
|
*/
|
||||||
|
export async function addFact({
|
||||||
|
content,
|
||||||
|
source,
|
||||||
|
addedBy,
|
||||||
|
approved = false,
|
||||||
|
}: schema.factTableTypes): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot add fact');
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(schema.factTable).values({
|
||||||
|
content,
|
||||||
|
source,
|
||||||
|
addedBy,
|
||||||
|
approved,
|
||||||
|
});
|
||||||
|
|
||||||
|
await invalidateCache('unused-facts');
|
||||||
|
} catch (error) {
|
||||||
|
handleDbError('Failed to add fact', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the ID of the most recently added fact
|
||||||
|
* @returns ID of the last inserted fact
|
||||||
|
*/
|
||||||
|
export async function getLastInsertedFactId(): Promise<number> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot get last inserted fact');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.select({ id: sql<number>`MAX(${schema.factTable.id})` })
|
||||||
|
.from(schema.factTable);
|
||||||
|
|
||||||
|
return result[0]?.id ?? 0;
|
||||||
|
} catch (error) {
|
||||||
|
return handleDbError('Failed to get last inserted fact ID', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a random fact that hasn't been used yet
|
||||||
|
* @returns Random fact object
|
||||||
|
*/
|
||||||
|
export async function getRandomUnusedFact(): Promise<schema.factTableTypes> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot get random unused fact');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = 'unused-facts';
|
||||||
|
const facts = await withCache<schema.factTableTypes[]>(
|
||||||
|
cacheKey,
|
||||||
|
async () => {
|
||||||
|
return (await db
|
||||||
|
.select()
|
||||||
|
.from(schema.factTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.factTable.approved, true),
|
||||||
|
isNull(schema.factTable.usedOn),
|
||||||
|
),
|
||||||
|
)) as schema.factTableTypes[];
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (facts.length === 0) {
|
||||||
|
await db
|
||||||
|
.update(schema.factTable)
|
||||||
|
.set({ usedOn: null })
|
||||||
|
.where(eq(schema.factTable.approved, true));
|
||||||
|
|
||||||
|
await invalidateCache(cacheKey);
|
||||||
|
return await getRandomUnusedFact();
|
||||||
|
}
|
||||||
|
|
||||||
|
return facts[
|
||||||
|
Math.floor(Math.random() * facts.length)
|
||||||
|
] as schema.factTableTypes;
|
||||||
|
} catch (error) {
|
||||||
|
return handleDbError('Failed to get random fact', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a fact as used
|
||||||
|
* @param id - ID of the fact to mark as used
|
||||||
|
*/
|
||||||
|
export async function markFactAsUsed(id: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot mark fact as used');
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(schema.factTable)
|
||||||
|
.set({ usedOn: new Date() })
|
||||||
|
.where(eq(schema.factTable.id, id));
|
||||||
|
|
||||||
|
await invalidateCache('unused-facts');
|
||||||
|
} catch (error) {
|
||||||
|
handleDbError('Failed to mark fact as used', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all pending facts that need approval
|
||||||
|
* @returns Array of pending fact objects
|
||||||
|
*/
|
||||||
|
export async function getPendingFacts(): Promise<schema.factTableTypes[]> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot get pending facts');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await db
|
||||||
|
.select()
|
||||||
|
.from(schema.factTable)
|
||||||
|
.where(eq(schema.factTable.approved, false))) as schema.factTableTypes[];
|
||||||
|
} catch (error) {
|
||||||
|
return handleDbError('Failed to get pending facts', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approve a fact
|
||||||
|
* @param id - ID of the fact to approve
|
||||||
|
*/
|
||||||
|
export async function approveFact(id: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot approve fact');
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(schema.factTable)
|
||||||
|
.set({ approved: true })
|
||||||
|
.where(eq(schema.factTable.id, id));
|
||||||
|
|
||||||
|
await invalidateCache('unused-facts');
|
||||||
|
} catch (error) {
|
||||||
|
handleDbError('Failed to approve fact', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a fact
|
||||||
|
* @param id - ID of the fact to delete
|
||||||
|
*/
|
||||||
|
export async function deleteFact(id: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot delete fact');
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(schema.factTable).where(eq(schema.factTable.id, id));
|
||||||
|
|
||||||
|
await invalidateCache('unused-facts');
|
||||||
|
} catch (error) {
|
||||||
|
return handleDbError('Failed to delete fact', error as Error);
|
||||||
|
}
|
||||||
|
}
|
275
src/db/functions/giveawayFunctions.ts
Normal file
275
src/db/functions/giveawayFunctions.ts
Normal file
|
@ -0,0 +1,275 @@
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db, ensureDbInitialized, handleDbError } from '../db.js';
|
||||||
|
import { selectGiveawayWinners } from '@/util/giveaways/utils.js';
|
||||||
|
import * as schema from '../schema.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a giveaway in the database
|
||||||
|
* @param giveawayData - Data for the giveaway
|
||||||
|
* @returns Created giveaway object
|
||||||
|
*/
|
||||||
|
export async function createGiveaway(giveawayData: {
|
||||||
|
channelId: string;
|
||||||
|
messageId: string;
|
||||||
|
endAt: Date;
|
||||||
|
prize: string;
|
||||||
|
winnerCount: number;
|
||||||
|
hostId: string;
|
||||||
|
requirements?: {
|
||||||
|
level?: number;
|
||||||
|
roleId?: string;
|
||||||
|
messageCount?: number;
|
||||||
|
requireAll?: boolean;
|
||||||
|
};
|
||||||
|
bonuses?: {
|
||||||
|
roles?: Array<{ id: string; entries: number }>;
|
||||||
|
levels?: Array<{ threshold: number; entries: number }>;
|
||||||
|
messages?: Array<{ threshold: number; entries: number }>;
|
||||||
|
};
|
||||||
|
}): Promise<schema.giveawayTableTypes> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot create giveaway');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [giveaway] = await db
|
||||||
|
.insert(schema.giveawayTable)
|
||||||
|
.values({
|
||||||
|
channelId: giveawayData.channelId,
|
||||||
|
messageId: giveawayData.messageId,
|
||||||
|
endAt: giveawayData.endAt,
|
||||||
|
prize: giveawayData.prize,
|
||||||
|
winnerCount: giveawayData.winnerCount,
|
||||||
|
hostId: giveawayData.hostId,
|
||||||
|
requiredLevel: giveawayData.requirements?.level,
|
||||||
|
requiredRoleId: giveawayData.requirements?.roleId,
|
||||||
|
requiredMessageCount: giveawayData.requirements?.messageCount,
|
||||||
|
requireAllCriteria: giveawayData.requirements?.requireAll ?? true,
|
||||||
|
bonusEntries:
|
||||||
|
giveawayData.bonuses as schema.giveawayTableTypes['bonusEntries'],
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return giveaway as schema.giveawayTableTypes;
|
||||||
|
} catch (error) {
|
||||||
|
return handleDbError('Failed to create giveaway', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a giveaway by ID or message ID
|
||||||
|
* @param id - ID of the giveaway
|
||||||
|
* @param isDbId - Whether the ID is a database ID
|
||||||
|
* @returns Giveaway object or undefined if not found
|
||||||
|
*/
|
||||||
|
export async function getGiveaway(
|
||||||
|
id: string | number,
|
||||||
|
isDbId = false,
|
||||||
|
): Promise<schema.giveawayTableTypes | undefined> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot get giveaway');
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDbId) {
|
||||||
|
const numId = typeof id === 'string' ? parseInt(id) : id;
|
||||||
|
const [giveaway] = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.giveawayTable)
|
||||||
|
.where(eq(schema.giveawayTable.id, numId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return giveaway as schema.giveawayTableTypes;
|
||||||
|
} else {
|
||||||
|
const [giveaway] = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.giveawayTable)
|
||||||
|
.where(eq(schema.giveawayTable.messageId, id as string))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return giveaway as schema.giveawayTableTypes;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return handleDbError('Failed to get giveaway', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active giveaways
|
||||||
|
* @returns Array of active giveaway objects
|
||||||
|
*/
|
||||||
|
export async function getActiveGiveaways(): Promise<
|
||||||
|
schema.giveawayTableTypes[]
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot get active giveaways');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await db
|
||||||
|
.select()
|
||||||
|
.from(schema.giveawayTable)
|
||||||
|
.where(
|
||||||
|
eq(schema.giveawayTable.status, 'active'),
|
||||||
|
)) as schema.giveawayTableTypes[];
|
||||||
|
} catch (error) {
|
||||||
|
return handleDbError('Failed to get active giveaways', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update giveaway participants
|
||||||
|
* @param messageId - ID of the giveaway message
|
||||||
|
* @param userId - ID of the user to add
|
||||||
|
* @param entries - Number of entries to add
|
||||||
|
* @return 'success' | 'already_entered' | 'inactive' | 'error'
|
||||||
|
*/
|
||||||
|
export async function addGiveawayParticipant(
|
||||||
|
messageId: string,
|
||||||
|
userId: string,
|
||||||
|
entries = 1,
|
||||||
|
): Promise<'success' | 'already_entered' | 'inactive' | 'error'> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot add participant');
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
const giveaway = await getGiveaway(messageId);
|
||||||
|
if (!giveaway || giveaway.status !== 'active') {
|
||||||
|
return 'inactive';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (giveaway.participants?.includes(userId)) {
|
||||||
|
return 'already_entered';
|
||||||
|
}
|
||||||
|
|
||||||
|
const participants = [...(giveaway.participants || [])];
|
||||||
|
for (let i = 0; i < entries; i++) {
|
||||||
|
participants.push(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(schema.giveawayTable)
|
||||||
|
.set({ participants: participants })
|
||||||
|
.where(eq(schema.giveawayTable.messageId, messageId));
|
||||||
|
|
||||||
|
return 'success';
|
||||||
|
} catch (error) {
|
||||||
|
handleDbError('Failed to add giveaway participant', error as Error);
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End a giveaway
|
||||||
|
* @param id - ID of the giveaway
|
||||||
|
* @param isDbId - Whether the ID is a database ID
|
||||||
|
* @param forceWinners - Array of user IDs to force as winners
|
||||||
|
* @return Updated giveaway object
|
||||||
|
*/
|
||||||
|
export async function endGiveaway(
|
||||||
|
id: string | number,
|
||||||
|
isDbId = false,
|
||||||
|
forceWinners?: string[],
|
||||||
|
): Promise<schema.giveawayTableTypes | undefined> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot end giveaway');
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const giveaway = await getGiveaway(id, isDbId);
|
||||||
|
if (!giveaway || giveaway.status !== 'active' || !giveaway.participants) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const winners = selectGiveawayWinners(
|
||||||
|
giveaway.participants,
|
||||||
|
giveaway.winnerCount,
|
||||||
|
forceWinners,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [updatedGiveaway] = await db
|
||||||
|
.update(schema.giveawayTable)
|
||||||
|
.set({
|
||||||
|
status: 'ended',
|
||||||
|
winnersIds: winners,
|
||||||
|
})
|
||||||
|
.where(eq(schema.giveawayTable.id, giveaway.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updatedGiveaway as schema.giveawayTableTypes;
|
||||||
|
} catch (error) {
|
||||||
|
return handleDbError('Failed to end giveaway', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reroll winners for a giveaway
|
||||||
|
* @param id - ID of the giveaway
|
||||||
|
* @return Updated giveaway object
|
||||||
|
*/
|
||||||
|
export async function rerollGiveaway(
|
||||||
|
id: string,
|
||||||
|
): Promise<schema.giveawayTableTypes | undefined> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot reroll giveaway');
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const giveaway = await getGiveaway(id, true);
|
||||||
|
if (
|
||||||
|
!giveaway ||
|
||||||
|
!giveaway.participants ||
|
||||||
|
giveaway.participants.length === 0 ||
|
||||||
|
giveaway.status !== 'ended'
|
||||||
|
) {
|
||||||
|
console.warn(
|
||||||
|
`Cannot reroll giveaway ${id}: Not found, no participants, or not ended.`,
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newWinners = selectGiveawayWinners(
|
||||||
|
giveaway.participants,
|
||||||
|
giveaway.winnerCount,
|
||||||
|
undefined,
|
||||||
|
giveaway.winnersIds ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newWinners.length === 0) {
|
||||||
|
console.warn(
|
||||||
|
`Cannot reroll giveaway ${id}: No eligible participants left after excluding previous winners.`,
|
||||||
|
);
|
||||||
|
return giveaway;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updatedGiveaway] = await db
|
||||||
|
.update(schema.giveawayTable)
|
||||||
|
.set({
|
||||||
|
winnersIds: newWinners,
|
||||||
|
})
|
||||||
|
.where(eq(schema.giveawayTable.id, giveaway.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updatedGiveaway as schema.giveawayTableTypes;
|
||||||
|
} catch (error) {
|
||||||
|
return handleDbError('Failed to reroll giveaway', error as Error);
|
||||||
|
}
|
||||||
|
}
|
329
src/db/functions/levelFunctions.ts
Normal file
329
src/db/functions/levelFunctions.ts
Normal file
|
@ -0,0 +1,329 @@
|
||||||
|
import { desc, eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import {
|
||||||
|
db,
|
||||||
|
ensureDbInitialized,
|
||||||
|
handleDbError,
|
||||||
|
invalidateCache,
|
||||||
|
withCache,
|
||||||
|
} from '../db.js';
|
||||||
|
import * as schema from '../schema.js';
|
||||||
|
import { calculateLevelFromXp } from '@/util/levelingSystem.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user level information or create a new entry if not found
|
||||||
|
* @param discordId - Discord ID of the user
|
||||||
|
* @returns User level object
|
||||||
|
*/
|
||||||
|
export async function getUserLevel(
|
||||||
|
discordId: string,
|
||||||
|
): Promise<schema.levelTableTypes> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot get user level');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `level-${discordId}`;
|
||||||
|
|
||||||
|
return await withCache<schema.levelTableTypes>(
|
||||||
|
cacheKey,
|
||||||
|
async () => {
|
||||||
|
const level = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.levelTable)
|
||||||
|
.where(eq(schema.levelTable.discordId, discordId))
|
||||||
|
.then((rows) => rows[0]);
|
||||||
|
|
||||||
|
if (level) {
|
||||||
|
return {
|
||||||
|
...level,
|
||||||
|
lastMessageTimestamp: level.lastMessageTimestamp ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const newLevel: schema.levelTableTypes = {
|
||||||
|
discordId,
|
||||||
|
xp: 0,
|
||||||
|
level: 0,
|
||||||
|
lastMessageTimestamp: new Date(),
|
||||||
|
messagesSent: 0,
|
||||||
|
reactionCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.insert(schema.levelTable).values(newLevel);
|
||||||
|
return newLevel;
|
||||||
|
},
|
||||||
|
300,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return handleDbError('Error getting user level', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add XP to a user, updating their level if necessary
|
||||||
|
* @param discordId - Discord ID of the user
|
||||||
|
* @param amount - Amount of XP to add
|
||||||
|
*/
|
||||||
|
export async function addXpToUser(
|
||||||
|
discordId: string,
|
||||||
|
amount: number,
|
||||||
|
): Promise<{
|
||||||
|
leveledUp: boolean;
|
||||||
|
newLevel: number;
|
||||||
|
oldLevel: number;
|
||||||
|
messagesSent: number;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot add xp to user');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `level-${discordId}`;
|
||||||
|
const userData = await getUserLevel(discordId);
|
||||||
|
const currentLevel = userData.level;
|
||||||
|
const currentXp = Number(userData.xp);
|
||||||
|
const xpToAdd = Number(amount);
|
||||||
|
|
||||||
|
userData.xp = currentXp + xpToAdd;
|
||||||
|
|
||||||
|
userData.lastMessageTimestamp = new Date();
|
||||||
|
userData.level = calculateLevelFromXp(userData.xp);
|
||||||
|
userData.messagesSent += 1;
|
||||||
|
|
||||||
|
await invalidateLeaderboardCache();
|
||||||
|
await invalidateCache(cacheKey);
|
||||||
|
await withCache<schema.levelTableTypes>(
|
||||||
|
cacheKey,
|
||||||
|
async () => {
|
||||||
|
const result = await db
|
||||||
|
.update(schema.levelTable)
|
||||||
|
.set({
|
||||||
|
xp: userData.xp,
|
||||||
|
level: userData.level,
|
||||||
|
lastMessageTimestamp: userData.lastMessageTimestamp,
|
||||||
|
messagesSent: userData.messagesSent,
|
||||||
|
})
|
||||||
|
.where(eq(schema.levelTable.discordId, discordId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return result[0] as schema.levelTableTypes;
|
||||||
|
},
|
||||||
|
300,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
leveledUp: userData.level > currentLevel,
|
||||||
|
newLevel: userData.level,
|
||||||
|
oldLevel: currentLevel,
|
||||||
|
messagesSent: userData.messagesSent,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return handleDbError('Error adding XP to user', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a user's rank on the XP leaderboard
|
||||||
|
* @param discordId - Discord ID of the user
|
||||||
|
* @returns User's rank on the leaderboard
|
||||||
|
*/
|
||||||
|
export async function getUserRank(discordId: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot get user rank');
|
||||||
|
}
|
||||||
|
|
||||||
|
const leaderboardCache = await getLeaderboardData();
|
||||||
|
|
||||||
|
if (leaderboardCache) {
|
||||||
|
const userIndex = leaderboardCache.findIndex(
|
||||||
|
(member) => member.discordId === discordId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userIndex !== -1) {
|
||||||
|
return userIndex + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
} catch (error) {
|
||||||
|
return handleDbError('Failed to get user rank', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear leaderboard cache
|
||||||
|
*/
|
||||||
|
export async function invalidateLeaderboardCache(): Promise<void> {
|
||||||
|
await invalidateCache('xp-leaderboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to get or create leaderboard data
|
||||||
|
* @returns Array of leaderboard data
|
||||||
|
*/
|
||||||
|
async function getLeaderboardData(): Promise<
|
||||||
|
Array<{
|
||||||
|
discordId: string;
|
||||||
|
xp: number;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot get leaderboard data');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = 'xp-leaderboard';
|
||||||
|
return withCache<Array<{ discordId: string; xp: number }>>(
|
||||||
|
cacheKey,
|
||||||
|
async () => {
|
||||||
|
return await db
|
||||||
|
.select({
|
||||||
|
discordId: schema.levelTable.discordId,
|
||||||
|
xp: schema.levelTable.xp,
|
||||||
|
})
|
||||||
|
.from(schema.levelTable)
|
||||||
|
.orderBy(desc(schema.levelTable.xp));
|
||||||
|
},
|
||||||
|
300,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return handleDbError('Failed to get leaderboard data', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increments the user's reaction count
|
||||||
|
* @param userId - Discord user ID
|
||||||
|
* @returns The updated reaction count
|
||||||
|
*/
|
||||||
|
export async function incrementUserReactionCount(
|
||||||
|
userId: string,
|
||||||
|
): Promise<number> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error(
|
||||||
|
'Database not initialized, cannot increment reaction count',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const levelData = await getUserLevel(userId);
|
||||||
|
|
||||||
|
const newCount = (levelData.reactionCount || 0) + 1;
|
||||||
|
await db
|
||||||
|
.update(schema.levelTable)
|
||||||
|
.set({ reactionCount: newCount })
|
||||||
|
.where(eq(schema.levelTable.discordId, userId));
|
||||||
|
await invalidateCache(`level-${userId}`);
|
||||||
|
|
||||||
|
return newCount;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error incrementing user reaction count:', error);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrements the user's reaction count (but not below zero)
|
||||||
|
* @param userId - Discord user ID
|
||||||
|
* @returns The updated reaction count
|
||||||
|
*/
|
||||||
|
export async function decrementUserReactionCount(
|
||||||
|
userId: string,
|
||||||
|
): Promise<number> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error(
|
||||||
|
'Database not initialized, cannot increment reaction count',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const levelData = await getUserLevel(userId);
|
||||||
|
|
||||||
|
const newCount = Math.max(0, levelData.reactionCount - 1);
|
||||||
|
await db
|
||||||
|
.update(schema.levelTable)
|
||||||
|
.set({ reactionCount: newCount < 0 ? 0 : newCount })
|
||||||
|
.where(eq(schema.levelTable.discordId, userId));
|
||||||
|
await invalidateCache(`level-${userId}`);
|
||||||
|
|
||||||
|
return newCount;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error decrementing user reaction count:', error);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the user's reaction count
|
||||||
|
* @param userId - Discord user ID
|
||||||
|
* @returns The user's reaction count
|
||||||
|
*/
|
||||||
|
export async function getUserReactionCount(userId: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot get user reaction count');
|
||||||
|
}
|
||||||
|
|
||||||
|
const levelData = await getUserLevel(userId);
|
||||||
|
return levelData.reactionCount;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting user reaction count:', error);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the XP leaderboard
|
||||||
|
* @param limit - Number of entries to return
|
||||||
|
* @returns Array of leaderboard entries
|
||||||
|
*/
|
||||||
|
export async function getLevelLeaderboard(
|
||||||
|
limit = 10,
|
||||||
|
): Promise<schema.levelTableTypes[]> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot get level leaderboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
const leaderboardCache = await getLeaderboardData();
|
||||||
|
|
||||||
|
if (leaderboardCache) {
|
||||||
|
const limitedCache = leaderboardCache.slice(0, limit);
|
||||||
|
|
||||||
|
const fullLeaderboard = await Promise.all(
|
||||||
|
limitedCache.map(async (entry) => {
|
||||||
|
const userData = await getUserLevel(entry.discordId);
|
||||||
|
return userData;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return fullLeaderboard;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await db
|
||||||
|
.select()
|
||||||
|
.from(schema.levelTable)
|
||||||
|
.orderBy(desc(schema.levelTable.xp))
|
||||||
|
.limit(limit)) as schema.levelTableTypes[];
|
||||||
|
} catch (error) {
|
||||||
|
return handleDbError('Failed to get leaderboard', error as Error);
|
||||||
|
}
|
||||||
|
}
|
160
src/db/functions/memberFunctions.ts
Normal file
160
src/db/functions/memberFunctions.ts
Normal file
|
@ -0,0 +1,160 @@
|
||||||
|
import { Collection, GuildMember } from 'discord.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import {
|
||||||
|
db,
|
||||||
|
ensureDbInitialized,
|
||||||
|
handleDbError,
|
||||||
|
invalidateCache,
|
||||||
|
withCache,
|
||||||
|
} from '../db.js';
|
||||||
|
import * as schema from '../schema.js';
|
||||||
|
import { getMemberModerationHistory } from './moderationFunctions.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all non-bot members currently in the server
|
||||||
|
* @returns Array of member objects
|
||||||
|
*/
|
||||||
|
export async function getAllMembers() {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot get members');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = 'nonBotMembers';
|
||||||
|
return await withCache<schema.memberTableTypes[]>(cacheKey, async () => {
|
||||||
|
const nonBotMembers = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.memberTable)
|
||||||
|
.where(eq(schema.memberTable.currentlyInServer, true));
|
||||||
|
return nonBotMembers;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return handleDbError('Failed to get all members', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set or update multiple members at once
|
||||||
|
* @param nonBotMembers - Array of member objects
|
||||||
|
*/
|
||||||
|
export async function setMembers(
|
||||||
|
nonBotMembers: Collection<string, GuildMember>,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot set members');
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
nonBotMembers.map(async (member) => {
|
||||||
|
const memberInfo = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.memberTable)
|
||||||
|
.where(eq(schema.memberTable.discordId, member.user.id));
|
||||||
|
|
||||||
|
if (memberInfo.length > 0) {
|
||||||
|
await updateMember({
|
||||||
|
discordId: member.user.id,
|
||||||
|
discordUsername: member.user.username,
|
||||||
|
currentlyInServer: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const members: typeof schema.memberTable.$inferInsert = {
|
||||||
|
discordId: member.user.id,
|
||||||
|
discordUsername: member.user.username,
|
||||||
|
};
|
||||||
|
await db.insert(schema.memberTable).values(members);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
handleDbError('Failed to set members', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get detailed information about a specific member including moderation history
|
||||||
|
* @param discordId - Discord ID of the user
|
||||||
|
* @returns Member object with moderation history
|
||||||
|
*/
|
||||||
|
export async function getMember(
|
||||||
|
discordId: string,
|
||||||
|
): Promise<
|
||||||
|
| (schema.memberTableTypes & { moderations: schema.moderationTableTypes[] })
|
||||||
|
| undefined
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot get member');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `${discordId}-memberInfo`;
|
||||||
|
|
||||||
|
const member = await withCache<schema.memberTableTypes>(
|
||||||
|
cacheKey,
|
||||||
|
async () => {
|
||||||
|
const memberData = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.memberTable)
|
||||||
|
.where(eq(schema.memberTable.discordId, discordId))
|
||||||
|
.then((rows) => rows[0]);
|
||||||
|
|
||||||
|
return memberData as schema.memberTableTypes;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const moderations = await getMemberModerationHistory(discordId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...member,
|
||||||
|
moderations,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return handleDbError('Failed to get member', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a member's information in the database
|
||||||
|
* @param discordId - Discord ID of the user
|
||||||
|
* @param discordUsername - New username of the member
|
||||||
|
* @param currentlyInServer - Whether the member is currently in the server
|
||||||
|
* @param currentlyBanned - Whether the member is currently banned
|
||||||
|
*/
|
||||||
|
export async function updateMember({
|
||||||
|
discordId,
|
||||||
|
discordUsername,
|
||||||
|
currentlyInServer,
|
||||||
|
currentlyBanned,
|
||||||
|
}: schema.memberTableTypes): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot update member');
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(schema.memberTable)
|
||||||
|
.set({
|
||||||
|
discordUsername,
|
||||||
|
currentlyInServer,
|
||||||
|
currentlyBanned,
|
||||||
|
})
|
||||||
|
.where(eq(schema.memberTable.discordId, discordId));
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
invalidateCache(`${discordId}-memberInfo`),
|
||||||
|
invalidateCache('nonBotMembers'),
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
handleDbError('Failed to update member', error as Error);
|
||||||
|
}
|
||||||
|
}
|
96
src/db/functions/moderationFunctions.ts
Normal file
96
src/db/functions/moderationFunctions.ts
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import {
|
||||||
|
db,
|
||||||
|
ensureDbInitialized,
|
||||||
|
handleDbError,
|
||||||
|
invalidateCache,
|
||||||
|
withCache,
|
||||||
|
} from '../db.js';
|
||||||
|
import * as schema from '../schema.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new moderation action to a member's history
|
||||||
|
* @param discordId - Discord ID of the user
|
||||||
|
* @param moderatorDiscordId - Discord ID of the moderator
|
||||||
|
* @param action - Type of action taken
|
||||||
|
* @param reason - Reason for the action
|
||||||
|
* @param duration - Duration of the action
|
||||||
|
* @param createdAt - Timestamp of when the action was taken
|
||||||
|
* @param expiresAt - Timestamp of when the action expires
|
||||||
|
* @param active - Wether the action is active or not
|
||||||
|
*/
|
||||||
|
export async function updateMemberModerationHistory({
|
||||||
|
discordId,
|
||||||
|
moderatorDiscordId,
|
||||||
|
action,
|
||||||
|
reason,
|
||||||
|
duration,
|
||||||
|
createdAt,
|
||||||
|
expiresAt,
|
||||||
|
active,
|
||||||
|
}: schema.moderationTableTypes): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error(
|
||||||
|
'Database not initialized, update member moderation history',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const moderationEntry = {
|
||||||
|
discordId,
|
||||||
|
moderatorDiscordId,
|
||||||
|
action,
|
||||||
|
reason,
|
||||||
|
duration,
|
||||||
|
createdAt,
|
||||||
|
expiresAt,
|
||||||
|
active,
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.insert(schema.moderationTable).values(moderationEntry);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
invalidateCache(`${discordId}-moderationHistory`),
|
||||||
|
invalidateCache(`${discordId}-memberInfo`),
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
handleDbError('Failed to update moderation history', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a member's moderation history
|
||||||
|
* @param discordId - Discord ID of the user
|
||||||
|
* @returns Array of moderation actions
|
||||||
|
*/
|
||||||
|
export async function getMemberModerationHistory(
|
||||||
|
discordId: string,
|
||||||
|
): Promise<schema.moderationTableTypes[]> {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error(
|
||||||
|
'Database not initialized, cannot get member moderation history',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `${discordId}-moderationHistory`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await withCache<schema.moderationTableTypes[]>(
|
||||||
|
cacheKey,
|
||||||
|
async () => {
|
||||||
|
const history = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.moderationTable)
|
||||||
|
.where(eq(schema.moderationTable.discordId, discordId));
|
||||||
|
return history as schema.moderationTableTypes[];
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return handleDbError('Failed to get moderation history', error as Error);
|
||||||
|
}
|
||||||
|
}
|
309
src/db/redis.ts
309
src/db/redis.ts
|
@ -1,9 +1,31 @@
|
||||||
import Redis from 'ioredis';
|
import Redis from 'ioredis';
|
||||||
import { loadConfig } from '../util/configLoader.js';
|
import { Client } from 'discord.js';
|
||||||
|
|
||||||
|
import { loadConfig } from '@/util/configLoader.js';
|
||||||
|
import {
|
||||||
|
logManagerNotification,
|
||||||
|
NotificationType,
|
||||||
|
notifyManagers,
|
||||||
|
} from '@/util/notificationHandler.js';
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const redis = new Redis(config.redisConnectionString);
|
|
||||||
|
|
||||||
|
// Redis connection state
|
||||||
|
let isRedisAvailable = false;
|
||||||
|
let redis: Redis;
|
||||||
|
let connectionAttempts = 0;
|
||||||
|
const MAX_RETRY_ATTEMPTS = config.redis.retryAttempts;
|
||||||
|
const INITIAL_RETRY_DELAY = config.redis.initialRetryDelay;
|
||||||
|
let hasNotifiedDisconnect = false;
|
||||||
|
let discordClient: Client | null = null;
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// Redis Utility Classes and Helper Functions
|
||||||
|
// ========================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom error class for Redis errors
|
||||||
|
*/
|
||||||
class RedisError extends Error {
|
class RedisError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
|
@ -14,77 +36,271 @@ class RedisError extends Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
redis.on('error', (error) => {
|
/**
|
||||||
console.error('Redis connection error:', error);
|
* Redis error handler
|
||||||
throw new RedisError('Failed to connect to Redis instance: ', error);
|
* @param errorMessage - The error message to log
|
||||||
});
|
* @param error - The error object
|
||||||
|
*/
|
||||||
|
const handleRedisError = (errorMessage: string, error: Error): null => {
|
||||||
|
console.error(`${errorMessage}:`, error);
|
||||||
|
throw new RedisError(errorMessage, error);
|
||||||
|
};
|
||||||
|
|
||||||
redis.on('connect', () => {
|
/**
|
||||||
console.log('Successfully connected to Redis');
|
* Sets the Discord client for sending notifications
|
||||||
});
|
* @param client - The Discord client
|
||||||
|
*/
|
||||||
|
export function setDiscordClient(client: Client): void {
|
||||||
|
discordClient = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the Redis connection with retry logic
|
||||||
|
*/
|
||||||
|
async function initializeRedisConnection() {
|
||||||
|
try {
|
||||||
|
if (redis && redis.status !== 'end' && redis.status !== 'close') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
redis = new Redis(config.redis.redisConnectionString, {
|
||||||
|
retryStrategy(times) {
|
||||||
|
connectionAttempts = times;
|
||||||
|
if (times >= MAX_RETRY_ATTEMPTS) {
|
||||||
|
const message = `Failed to connect to Redis after ${times} attempts. Caching will be disabled.`;
|
||||||
|
console.warn(message);
|
||||||
|
|
||||||
|
if (!hasNotifiedDisconnect && discordClient) {
|
||||||
|
logManagerNotification(NotificationType.REDIS_CONNECTION_LOST);
|
||||||
|
notifyManagers(
|
||||||
|
discordClient,
|
||||||
|
NotificationType.REDIS_CONNECTION_LOST,
|
||||||
|
`Connection attempts exhausted after ${times} tries. Caching is now disabled.`,
|
||||||
|
);
|
||||||
|
hasNotifiedDisconnect = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = Math.min(INITIAL_RETRY_DELAY * Math.pow(2, times), 30000);
|
||||||
|
console.log(
|
||||||
|
`Retrying Redis connection in ${delay}ms... (Attempt ${times + 1}/${MAX_RETRY_ATTEMPTS})`,
|
||||||
|
);
|
||||||
|
return delay;
|
||||||
|
},
|
||||||
|
maxRetriesPerRequest: 3,
|
||||||
|
enableOfflineQueue: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// Redis Events
|
||||||
|
// ========================
|
||||||
|
redis.on('error', (error: Error) => {
|
||||||
|
console.error('Redis Connection Error:', error);
|
||||||
|
isRedisAvailable = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
redis.on('connect', () => {
|
||||||
|
console.info('Successfully connected to Redis');
|
||||||
|
isRedisAvailable = true;
|
||||||
|
connectionAttempts = 0;
|
||||||
|
|
||||||
|
if (hasNotifiedDisconnect && discordClient) {
|
||||||
|
logManagerNotification(NotificationType.REDIS_CONNECTION_RESTORED);
|
||||||
|
notifyManagers(
|
||||||
|
discordClient,
|
||||||
|
NotificationType.REDIS_CONNECTION_RESTORED,
|
||||||
|
);
|
||||||
|
hasNotifiedDisconnect = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
redis.on('close', () => {
|
||||||
|
console.warn('Redis connection closed');
|
||||||
|
isRedisAvailable = false;
|
||||||
|
|
||||||
|
// Try to reconnect after some time if we've not exceeded max attempts
|
||||||
|
if (connectionAttempts < MAX_RETRY_ATTEMPTS) {
|
||||||
|
const delay = Math.min(
|
||||||
|
INITIAL_RETRY_DELAY * Math.pow(2, connectionAttempts),
|
||||||
|
30000,
|
||||||
|
);
|
||||||
|
setTimeout(initializeRedisConnection, delay);
|
||||||
|
} else if (!hasNotifiedDisconnect && discordClient) {
|
||||||
|
logManagerNotification(NotificationType.REDIS_CONNECTION_LOST);
|
||||||
|
notifyManagers(
|
||||||
|
discordClient,
|
||||||
|
NotificationType.REDIS_CONNECTION_LOST,
|
||||||
|
'Connection closed and max retry attempts reached.',
|
||||||
|
);
|
||||||
|
hasNotifiedDisconnect = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
redis.on('reconnecting', () => {
|
||||||
|
console.info('Attempting to reconnect to Redis...');
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize Redis:', error);
|
||||||
|
isRedisAvailable = false;
|
||||||
|
|
||||||
|
if (!hasNotifiedDisconnect && discordClient) {
|
||||||
|
logManagerNotification(
|
||||||
|
NotificationType.REDIS_CONNECTION_LOST,
|
||||||
|
`Error: ${error}`,
|
||||||
|
);
|
||||||
|
notifyManagers(
|
||||||
|
discordClient,
|
||||||
|
NotificationType.REDIS_CONNECTION_LOST,
|
||||||
|
`Initialization error: ${error}`,
|
||||||
|
);
|
||||||
|
hasNotifiedDisconnect = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Redis connection
|
||||||
|
initializeRedisConnection();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Redis is currently available, and attempt to reconnect if not
|
||||||
|
* @returns - True if Redis is connected and available
|
||||||
|
*/
|
||||||
|
export async function ensureRedisConnection(): Promise<boolean> {
|
||||||
|
if (!isRedisAvailable) {
|
||||||
|
await initializeRedisConnection();
|
||||||
|
}
|
||||||
|
return isRedisAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// Redis Functions
|
||||||
|
// ========================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to set a key in Redis
|
||||||
|
* @param key - The key to set
|
||||||
|
* @param value - The value to set
|
||||||
|
* @param ttl - The time to live for the key
|
||||||
|
* @returns - 'OK' if successful
|
||||||
|
*/
|
||||||
export async function set(
|
export async function set(
|
||||||
key: string,
|
key: string,
|
||||||
value: string,
|
value: string,
|
||||||
ttl?: number,
|
ttl?: number,
|
||||||
): Promise<'OK'> {
|
): Promise<'OK' | null> {
|
||||||
try {
|
if (!(await ensureRedisConnection())) {
|
||||||
await redis.set(key, value);
|
console.warn('Redis unavailable, skipping set operation');
|
||||||
if (ttl) await redis.expire(key, ttl);
|
return null;
|
||||||
} catch (error) {
|
}
|
||||||
console.error('Redis set error: ', error);
|
|
||||||
throw new RedisError(`Failed to set key: ${key}, `, error as Error);
|
try {
|
||||||
|
await redis.set(`bot:${key}`, value);
|
||||||
|
if (ttl) await redis.expire(`bot:${key}`, ttl);
|
||||||
|
return 'OK';
|
||||||
|
} catch (error) {
|
||||||
|
return handleRedisError(`Failed to set key: ${key}`, error as Error);
|
||||||
}
|
}
|
||||||
return Promise.resolve('OK');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to set a key in Redis with a JSON value
|
||||||
|
* @param key - The key to set
|
||||||
|
* @param value - The value to set
|
||||||
|
* @param ttl - The time to live for the key
|
||||||
|
* @returns - 'OK' if successful
|
||||||
|
*/
|
||||||
export async function setJson<T>(
|
export async function setJson<T>(
|
||||||
key: string,
|
key: string,
|
||||||
value: T,
|
value: T,
|
||||||
ttl?: number,
|
ttl?: number,
|
||||||
): Promise<'OK'> {
|
): Promise<'OK' | null> {
|
||||||
return await set(key, JSON.stringify(value), ttl);
|
return await set(key, JSON.stringify(value), ttl);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function incr(key: string): Promise<number> {
|
/**
|
||||||
|
* Increments a key in Redis
|
||||||
|
* @param key - The key to increment
|
||||||
|
* @returns - The new value of the key, or null if Redis is unavailable
|
||||||
|
*/
|
||||||
|
export async function incr(key: string): Promise<number | null> {
|
||||||
|
if (!(await ensureRedisConnection())) {
|
||||||
|
console.warn('Redis unavailable, skipping increment operation');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await redis.incr(key);
|
return await redis.incr(`bot:${key}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Redis increment error: ', error);
|
return handleRedisError(`Failed to increment key: ${key}`, error as Error);
|
||||||
throw new RedisError(`Failed to increment key: ${key}, `, error as Error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exists(key: string): Promise<boolean> {
|
/**
|
||||||
|
* Checks if a key exists in Redis
|
||||||
|
* @param key - The key to check
|
||||||
|
* @returns - True if the key exists, false otherwise, or null if Redis is unavailable
|
||||||
|
*/
|
||||||
|
export async function exists(key: string): Promise<boolean | null> {
|
||||||
|
if (!(await ensureRedisConnection())) {
|
||||||
|
console.warn('Redis unavailable, skipping exists operation');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return (await redis.exists(key)) === 1;
|
return (await redis.exists(`bot:${key}`)) === 1;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Redis exists error: ', error);
|
return handleRedisError(
|
||||||
throw new RedisError(
|
`Failed to check if key exists: ${key}`,
|
||||||
`Failed to check if key exists: ${key}, `,
|
|
||||||
error as Error,
|
error as Error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the value of a key in Redis
|
||||||
|
* @param key - The key to get
|
||||||
|
* @returns - The value of the key, or null if the key does not exist or Redis is unavailable
|
||||||
|
*/
|
||||||
export async function get(key: string): Promise<string | null> {
|
export async function get(key: string): Promise<string | null> {
|
||||||
|
if (!(await ensureRedisConnection())) {
|
||||||
|
console.warn('Redis unavailable, skipping get operation');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await redis.get(key);
|
return await redis.get(`bot:${key}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Redis get error: ', error);
|
return handleRedisError(`Failed to get key: ${key}`, error as Error);
|
||||||
throw new RedisError(`Failed to get key: ${key}, `, error as Error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function mget(...keys: string[]): Promise<(string | null)[]> {
|
/**
|
||||||
|
* Gets the values of multiple keys in Redis
|
||||||
|
* @param keys - The keys to get
|
||||||
|
* @returns - The values of the keys, or null if Redis is unavailable
|
||||||
|
*/
|
||||||
|
export async function mget(
|
||||||
|
...keys: string[]
|
||||||
|
): Promise<(string | null)[] | null> {
|
||||||
|
if (!(await ensureRedisConnection())) {
|
||||||
|
console.warn('Redis unavailable, skipping mget operation');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await redis.mget(keys);
|
return await redis.mget(...keys.map((key) => `bot:${key}`));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Redis mget error: ', error);
|
return handleRedisError('Failed to get keys', error as Error);
|
||||||
throw new RedisError(`Failed to get keys: ${keys}, `, error as Error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the value of a key in Redis and parses it as a JSON object
|
||||||
|
* @param key - The key to get
|
||||||
|
* @returns - The parsed JSON value of the key, or null if the key does not exist or Redis is unavailable
|
||||||
|
*/
|
||||||
export async function getJson<T>(key: string): Promise<T | null> {
|
export async function getJson<T>(key: string): Promise<T | null> {
|
||||||
const value = await get(key);
|
const value = await get(key);
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
|
@ -95,11 +311,28 @@ export async function getJson<T>(key: string): Promise<T | null> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function del(key: string): Promise<number> {
|
/**
|
||||||
|
* Deletes a key in Redis
|
||||||
|
* @param key - The key to delete
|
||||||
|
* @returns - The number of keys that were deleted, or null if Redis is unavailable
|
||||||
|
*/
|
||||||
|
export async function del(key: string): Promise<number | null> {
|
||||||
|
if (!(await ensureRedisConnection())) {
|
||||||
|
console.warn('Redis unavailable, skipping delete operation');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await redis.del(key);
|
return await redis.del(`bot:${key}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Redis del error: ', error);
|
return handleRedisError(`Failed to delete key: ${key}`, error as Error);
|
||||||
throw new RedisError(`Failed to delete key: ${key}, `, error as Error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Redis is currently available
|
||||||
|
* @returns - True if Redis is connected and available
|
||||||
|
*/
|
||||||
|
export function isRedisConnected(): boolean {
|
||||||
|
return isRedisAvailable;
|
||||||
|
}
|
||||||
|
|
122
src/db/schema.ts
122
src/db/schema.ts
|
@ -1,11 +1,13 @@
|
||||||
import {
|
import {
|
||||||
boolean,
|
boolean,
|
||||||
integer,
|
integer,
|
||||||
|
json,
|
||||||
|
jsonb,
|
||||||
pgTable,
|
pgTable,
|
||||||
timestamp,
|
timestamp,
|
||||||
varchar,
|
varchar,
|
||||||
} from 'drizzle-orm/pg-core';
|
} from 'drizzle-orm/pg-core';
|
||||||
import { relations } from 'drizzle-orm';
|
import { InferSelectModel, relations } from 'drizzle-orm';
|
||||||
|
|
||||||
export interface memberTableTypes {
|
export interface memberTableTypes {
|
||||||
id?: number;
|
id?: number;
|
||||||
|
@ -25,6 +27,28 @@ export const memberTable = pgTable('members', {
|
||||||
currentlyMuted: boolean('currently_muted').notNull().default(false),
|
currentlyMuted: boolean('currently_muted').notNull().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export interface levelTableTypes {
|
||||||
|
id?: number;
|
||||||
|
discordId: string;
|
||||||
|
xp: number;
|
||||||
|
level: number;
|
||||||
|
messagesSent: number;
|
||||||
|
reactionCount: number;
|
||||||
|
lastMessageTimestamp?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const levelTable = pgTable('levels', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
discordId: varchar('discord_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => memberTable.discordId, { onDelete: 'cascade' }),
|
||||||
|
xp: integer('xp').notNull().default(0),
|
||||||
|
level: integer('level').notNull().default(0),
|
||||||
|
messagesSent: integer('messages_sent').notNull().default(0),
|
||||||
|
reactionCount: integer('reaction_count').notNull().default(0),
|
||||||
|
lastMessageTimestamp: timestamp('last_message_timestamp'),
|
||||||
|
});
|
||||||
|
|
||||||
export interface moderationTableTypes {
|
export interface moderationTableTypes {
|
||||||
id?: number;
|
id?: number;
|
||||||
discordId: string;
|
discordId: string;
|
||||||
|
@ -51,8 +75,20 @@ export const moderationTable = pgTable('moderations', {
|
||||||
active: boolean('active').notNull().default(true),
|
active: boolean('active').notNull().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const memberRelations = relations(memberTable, ({ many }) => ({
|
export const memberRelations = relations(memberTable, ({ many, one }) => ({
|
||||||
moderations: many(moderationTable),
|
moderations: many(moderationTable),
|
||||||
|
levels: one(levelTable, {
|
||||||
|
fields: [memberTable.discordId],
|
||||||
|
references: [levelTable.discordId],
|
||||||
|
}),
|
||||||
|
facts: many(factTable),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const levelRelations = relations(levelTable, ({ one }) => ({
|
||||||
|
member: one(memberTable, {
|
||||||
|
fields: [levelTable.discordId],
|
||||||
|
references: [memberTable.discordId],
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const moderationRelations = relations(moderationTable, ({ one }) => ({
|
export const moderationRelations = relations(moderationTable, ({ one }) => ({
|
||||||
|
@ -61,3 +97,85 @@ export const moderationRelations = relations(moderationTable, ({ one }) => ({
|
||||||
references: [memberTable.discordId],
|
references: [memberTable.discordId],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export type factTableTypes = {
|
||||||
|
id?: number;
|
||||||
|
content: string;
|
||||||
|
source?: string;
|
||||||
|
addedBy: string;
|
||||||
|
addedAt?: Date;
|
||||||
|
approved?: boolean;
|
||||||
|
usedOn?: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const factTable = pgTable('facts', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
content: varchar('content').notNull(),
|
||||||
|
source: varchar('source'),
|
||||||
|
addedBy: varchar('added_by').notNull(),
|
||||||
|
addedAt: timestamp('added_at').defaultNow().notNull(),
|
||||||
|
approved: boolean('approved').default(false).notNull(),
|
||||||
|
usedOn: timestamp('used_on'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type giveawayTableTypes = InferSelectModel<typeof giveawayTable> & {
|
||||||
|
bonusEntries: {
|
||||||
|
roles?: Array<{ id: string; entries: number }>;
|
||||||
|
levels?: Array<{ threshold: number; entries: number }>;
|
||||||
|
messages?: Array<{ threshold: number; entries: number }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const giveawayTable = pgTable('giveaways', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
channelId: varchar('channel_id').notNull(),
|
||||||
|
messageId: varchar('message_id').notNull().unique(),
|
||||||
|
createdAt: timestamp('created_at').defaultNow(),
|
||||||
|
endAt: timestamp('end_at').notNull(),
|
||||||
|
prize: varchar('prize').notNull(),
|
||||||
|
winnerCount: integer('winner_count').notNull().default(1),
|
||||||
|
hostId: varchar('host_id')
|
||||||
|
.references(() => memberTable.discordId)
|
||||||
|
.notNull(),
|
||||||
|
status: varchar('status').notNull().default('active'),
|
||||||
|
participants: varchar('participants').array().default([]),
|
||||||
|
winnersIds: varchar('winners_ids').array().default([]),
|
||||||
|
requiredLevel: integer('required_level'),
|
||||||
|
requiredRoleId: varchar('required_role_id'),
|
||||||
|
requiredMessageCount: integer('required_message_count'),
|
||||||
|
requireAllCriteria: boolean('require_all_criteria').default(true),
|
||||||
|
bonusEntries: jsonb('bonus_entries').default({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type userAchievementsTableTypes = InferSelectModel<
|
||||||
|
typeof userAchievementsTable
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const userAchievementsTable = pgTable('user_achievements', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
discordId: varchar('user_id', { length: 50 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => memberTable.discordId),
|
||||||
|
achievementId: integer('achievement_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => achievementDefinitionsTable.id),
|
||||||
|
earnedAt: timestamp('earned_at'),
|
||||||
|
progress: integer().default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type achievementDefinitionsTableTypes = InferSelectModel<
|
||||||
|
typeof achievementDefinitionsTable
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const achievementDefinitionsTable = pgTable('achievement_definitions', {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
name: varchar({ length: 100 }).notNull(),
|
||||||
|
description: varchar({ length: 255 }).notNull(),
|
||||||
|
imageUrl: varchar('image_url', { length: 255 }),
|
||||||
|
requirement: json().notNull(),
|
||||||
|
requirementType: varchar('requirement_type', { length: 50 }).notNull(),
|
||||||
|
threshold: integer().notNull(),
|
||||||
|
rewardType: varchar('reward_type', { length: 50 }),
|
||||||
|
rewardValue: varchar('reward_value', { length: 50 }),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { GatewayIntentBits } from 'discord.js';
|
import { GatewayIntentBits } from 'discord.js';
|
||||||
import { ExtendedClient } from './structures/ExtendedClient.js';
|
import { ExtendedClient } from '@/structures/ExtendedClient.js';
|
||||||
import { loadConfig } from './util/configLoader.js';
|
import { loadConfig } from '@/util/configLoader.js';
|
||||||
|
|
||||||
async function startBot() {
|
async function startBot() {
|
||||||
try {
|
try {
|
||||||
|
@ -13,6 +13,7 @@ async function startBot() {
|
||||||
GatewayIntentBits.GuildMembers,
|
GatewayIntentBits.GuildMembers,
|
||||||
GatewayIntentBits.GuildMessages,
|
GatewayIntentBits.GuildMessages,
|
||||||
GatewayIntentBits.MessageContent,
|
GatewayIntentBits.MessageContent,
|
||||||
|
GatewayIntentBits.GuildMessageReactions,
|
||||||
GatewayIntentBits.GuildModeration,
|
GatewayIntentBits.GuildModeration,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
@ -7,9 +7,9 @@ import {
|
||||||
PermissionOverwrites,
|
PermissionOverwrites,
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
|
|
||||||
import { ChannelLogAction } from '../util/logging/types.js';
|
import { ChannelLogAction } from '@/util/logging/types.js';
|
||||||
import { Event } from '../types/EventTypes.js';
|
import { Event } from '@/types/EventTypes.js';
|
||||||
import logAction from '../util/logging/logAction.js';
|
import logAction from '@/util/logging/logAction.js';
|
||||||
|
|
||||||
function arePermissionsEqual(
|
function arePermissionsEqual(
|
||||||
oldPerms: Map<string, PermissionOverwrites>,
|
oldPerms: Map<string, PermissionOverwrites>,
|
||||||
|
|
|
@ -1,40 +1,224 @@
|
||||||
import { Events, Interaction } from 'discord.js';
|
import {
|
||||||
|
Events,
|
||||||
|
Interaction,
|
||||||
|
ButtonInteraction,
|
||||||
|
ModalSubmitInteraction,
|
||||||
|
StringSelectMenuInteraction,
|
||||||
|
} from 'discord.js';
|
||||||
|
|
||||||
import { ExtendedClient } from '../structures/ExtendedClient.js';
|
import { Event } from '@/types/EventTypes.js';
|
||||||
import { Event } from '../types/EventTypes.js';
|
import { approveFact, deleteFact } from '@/db/db.js';
|
||||||
|
import * as GiveawayManager from '@/util/giveaways/giveawayManager.js';
|
||||||
|
import { ExtendedClient } from '@/structures/ExtendedClient.js';
|
||||||
|
import { safelyRespond, validateInteraction } from '@/util/helpers.js';
|
||||||
|
import { processCommandAchievements } from '@/util/achievementManager.js';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: Events.InteractionCreate,
|
name: Events.InteractionCreate,
|
||||||
execute: async (interaction: Interaction) => {
|
execute: async (interaction: Interaction) => {
|
||||||
|
if (!(await validateInteraction(interaction))) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (interaction.isCommand()) {
|
||||||
|
await handleCommand(interaction);
|
||||||
|
} else if (interaction.isButton()) {
|
||||||
|
await handleButton(interaction);
|
||||||
|
} else if (interaction.isModalSubmit()) {
|
||||||
|
await handleModal(interaction);
|
||||||
|
} else if (interaction.isStringSelectMenu()) {
|
||||||
|
await handleSelectMenu(interaction);
|
||||||
|
} else {
|
||||||
|
console.warn('Unhandled interaction type:', interaction);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
handleInteractionError(error, interaction);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
} as Event<typeof Events.InteractionCreate>;
|
||||||
|
|
||||||
|
async function handleCommand(interaction: Interaction) {
|
||||||
if (!interaction.isCommand()) return;
|
if (!interaction.isCommand()) return;
|
||||||
|
|
||||||
const client = interaction.client as ExtendedClient;
|
const client = interaction.client as ExtendedClient;
|
||||||
const command = client.commands.get(interaction.commandName);
|
const command = client.commands.get(interaction.commandName);
|
||||||
|
|
||||||
if (!command) {
|
if (!command) {
|
||||||
console.error(
|
console.error(`No command matching ${interaction.commandName} was found.`);
|
||||||
`No command matching ${interaction.commandName} was found.`,
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.isChatInputCommand()) {
|
||||||
|
await command.execute(interaction);
|
||||||
|
await processCommandAchievements(
|
||||||
|
interaction.user.id,
|
||||||
|
command.data.name,
|
||||||
|
interaction.guild!,
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
interaction.isUserContextMenuCommand() ||
|
||||||
|
interaction.isMessageContextMenuCommand()
|
||||||
|
) {
|
||||||
|
// @ts-expect-error
|
||||||
|
await command.execute(interaction);
|
||||||
|
await processCommandAchievements(
|
||||||
|
interaction.user.id,
|
||||||
|
command.data.name,
|
||||||
|
interaction.guild!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleButton(interaction: Interaction) {
|
||||||
|
if (!interaction.isButton()) return;
|
||||||
|
|
||||||
|
const { customId } = interaction;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const giveawayHandlers: Record<
|
||||||
|
string,
|
||||||
|
(buttonInteraction: ButtonInteraction) => Promise<void>
|
||||||
|
> = {
|
||||||
|
giveaway_start_builder: GiveawayManager.builder.startGiveawayBuilder,
|
||||||
|
giveaway_next: GiveawayManager.builder.nextBuilderStep,
|
||||||
|
giveaway_previous: GiveawayManager.builder.previousBuilderStep,
|
||||||
|
giveaway_set_prize: GiveawayManager.modals.showPrizeModal,
|
||||||
|
giveaway_set_duration: GiveawayManager.dropdowns.showDurationSelect,
|
||||||
|
giveaway_set_winners: GiveawayManager.dropdowns.showWinnerSelect,
|
||||||
|
giveaway_set_requirements: GiveawayManager.modals.showRequirementsModal,
|
||||||
|
giveaway_toggle_logic: GiveawayManager.toggleRequirementLogic,
|
||||||
|
giveaway_set_channel:
|
||||||
|
(interaction.guild?.channels.cache.size ?? 0) > 25
|
||||||
|
? GiveawayManager.modals.showChannelSelectModal
|
||||||
|
: GiveawayManager.dropdowns.showChannelSelect,
|
||||||
|
giveaway_bonus_entries: GiveawayManager.modals.showBonusEntriesModal,
|
||||||
|
giveaway_set_ping_role:
|
||||||
|
(interaction.guild?.roles.cache.size ?? 0) > 25
|
||||||
|
? GiveawayManager.modals.showPingRoleSelectModal
|
||||||
|
: GiveawayManager.dropdowns.showPingRoleSelect,
|
||||||
|
giveaway_publish: GiveawayManager.publishGiveaway,
|
||||||
|
enter_giveaway: GiveawayManager.handlers.handleGiveawayEntry,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (giveawayHandlers[customId]) {
|
||||||
|
await giveawayHandlers[customId](interaction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
customId.startsWith('approve_fact_') ||
|
||||||
|
customId.startsWith('reject_fact_')
|
||||||
|
) {
|
||||||
|
await handleFactModeration(interaction, customId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn('Unhandled button interaction:', customId);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Button interaction failed: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFactModeration(
|
||||||
|
interaction: Interaction,
|
||||||
|
customId: string,
|
||||||
|
) {
|
||||||
|
if (!interaction.isButton()) return;
|
||||||
|
if (!interaction.memberPermissions?.has('ModerateMembers')) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'You do not have permission to moderate facts.',
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const factId = parseInt(customId.replace(/^(approve|reject)_fact_/, ''), 10);
|
||||||
|
const isApproval = customId.startsWith('approve_fact_');
|
||||||
|
|
||||||
|
if (isApproval) {
|
||||||
|
await approveFact(factId);
|
||||||
|
await interaction.update({
|
||||||
|
content: `✅ Fact #${factId} has been approved by <@${interaction.user.id}>`,
|
||||||
|
components: [],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await deleteFact(factId);
|
||||||
|
await interaction.update({
|
||||||
|
content: `❌ Fact #${factId} has been rejected by <@${interaction.user.id}>`,
|
||||||
|
components: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleModal(interaction: Interaction) {
|
||||||
|
if (!interaction.isModalSubmit()) return;
|
||||||
|
|
||||||
|
const { customId } = interaction;
|
||||||
|
const modalHandlers: Record<
|
||||||
|
string,
|
||||||
|
(modalInteraction: ModalSubmitInteraction) => Promise<void>
|
||||||
|
> = {
|
||||||
|
giveaway_prize_modal: GiveawayManager.handlers.handlePrizeSubmit,
|
||||||
|
giveaway_custom_duration:
|
||||||
|
GiveawayManager.handlers.handleCustomDurationSubmit,
|
||||||
|
giveaway_requirements_modal:
|
||||||
|
GiveawayManager.handlers.handleRequirementsSubmit,
|
||||||
|
giveaway_bonus_entries_modal:
|
||||||
|
GiveawayManager.handlers.handleBonusEntriesSubmit,
|
||||||
|
giveaway_ping_role_id_modal:
|
||||||
|
GiveawayManager.handlers.handlePingRoleIdSubmit,
|
||||||
|
giveaway_channel_id_modal: GiveawayManager.handlers.handleChannelIdSubmit,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (modalHandlers[customId]) {
|
||||||
|
await modalHandlers[customId](interaction);
|
||||||
|
} else {
|
||||||
|
console.warn('Unhandled modal submission interaction:', customId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Modal submission failed: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSelectMenu(interaction: Interaction) {
|
||||||
|
if (!interaction.isStringSelectMenu()) return;
|
||||||
|
|
||||||
|
const { customId } = interaction;
|
||||||
|
const selectHandlers: Record<
|
||||||
|
string,
|
||||||
|
(selectInteraction: StringSelectMenuInteraction) => Promise<void>
|
||||||
|
> = {
|
||||||
|
giveaway_duration_select: GiveawayManager.handlers.handleDurationSelect,
|
||||||
|
giveaway_winners_select: GiveawayManager.handlers.handleWinnerSelect,
|
||||||
|
giveaway_channel_select: GiveawayManager.handlers.handleChannelSelect,
|
||||||
|
giveaway_ping_role_select: GiveawayManager.handlers.handlePingRoleSelect,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (selectHandlers[customId]) {
|
||||||
|
await selectHandlers[customId](interaction);
|
||||||
|
} else {
|
||||||
|
console.warn('Unhandled string select menu interaction:', customId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Select menu interaction failed: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInteractionError(error: unknown, interaction: Interaction) {
|
||||||
|
console.error('Interaction error:', error);
|
||||||
|
|
||||||
|
const isUnknownInteractionError =
|
||||||
|
(error as { code?: number })?.code === 10062 ||
|
||||||
|
String(error).includes('Unknown interaction');
|
||||||
|
|
||||||
|
if (isUnknownInteractionError) {
|
||||||
|
console.warn(
|
||||||
|
'Interaction expired before response could be sent (code 10062)',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const errorMessage = 'An error occurred while processing your request.';
|
||||||
await command.execute(interaction);
|
safelyRespond(interaction, errorMessage).catch(console.error);
|
||||||
} catch (error) {
|
}
|
||||||
console.error(`Error executing ${interaction.commandName}`);
|
|
||||||
console.error(error);
|
|
||||||
|
|
||||||
if (interaction.replied || interaction.deferred) {
|
|
||||||
await interaction.followUp({
|
|
||||||
content: 'There was an error while executing this command!',
|
|
||||||
flags: ['Ephemeral'],
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await interaction.reply({
|
|
||||||
content: 'There was an error while executing this command!',
|
|
||||||
flags: ['Ephemeral'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
} as Event<typeof Events.InteractionCreate>;
|
|
||||||
|
|
|
@ -1,10 +1,15 @@
|
||||||
import { Events, Guild, GuildMember, PartialGuildMember } from 'discord.js';
|
import {
|
||||||
|
Collection,
|
||||||
|
Events,
|
||||||
|
GuildMember,
|
||||||
|
PartialGuildMember,
|
||||||
|
} from 'discord.js';
|
||||||
|
|
||||||
import { updateMember, setMembers } from '../db/db.js';
|
import { updateMember, setMembers } from '@/db/db.js';
|
||||||
import { generateMemberBanner } from '../util/helpers.js';
|
import { generateMemberBanner } from '@/util/helpers.js';
|
||||||
import { loadConfig } from '../util/configLoader.js';
|
import { loadConfig } from '@/util/configLoader.js';
|
||||||
import { Event } from '../types/EventTypes.js';
|
import { Event } from '@/types/EventTypes.js';
|
||||||
import logAction from '../util/logging/logAction.js';
|
import logAction from '@/util/logging/logAction.js';
|
||||||
|
|
||||||
export const memberJoin: Event<typeof Events.GuildMemberAdd> = {
|
export const memberJoin: Event<typeof Events.GuildMemberAdd> = {
|
||||||
name: Events.GuildMemberAdd,
|
name: Events.GuildMemberAdd,
|
||||||
|
@ -19,12 +24,9 @@ export const memberJoin: Event<typeof Events.GuildMemberAdd> = {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await setMembers([
|
const memberCollection = new Collection<string, GuildMember>();
|
||||||
{
|
memberCollection.set(member.user.id, member);
|
||||||
discordId: member.user.id,
|
await setMembers(memberCollection);
|
||||||
discordUsername: member.user.username,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!member.user.bot) {
|
if (!member.user.bot) {
|
||||||
const attachment = await generateMemberBanner({
|
const attachment = await generateMemberBanner({
|
||||||
|
|
|
@ -1,7 +1,18 @@
|
||||||
import { AuditLogEvent, Events, Message, PartialMessage } from 'discord.js';
|
import { AuditLogEvent, Events, Message, PartialMessage } from 'discord.js';
|
||||||
|
|
||||||
import { Event } from '../types/EventTypes.js';
|
import { Event } from '@/types/EventTypes.js';
|
||||||
import logAction from '../util/logging/logAction.js';
|
import { loadConfig } from '@/util/configLoader.js';
|
||||||
|
import {
|
||||||
|
addCountingReactions,
|
||||||
|
processCountingMessage,
|
||||||
|
resetCounting,
|
||||||
|
} from '@/util/countingManager.js';
|
||||||
|
import logAction from '@/util/logging/logAction.js';
|
||||||
|
import {
|
||||||
|
checkAndAssignLevelRoles,
|
||||||
|
processMessage,
|
||||||
|
} from '@/util/levelingSystem.js';
|
||||||
|
import { processLevelUpAchievements } from '@/util/achievementManager.js';
|
||||||
|
|
||||||
export const messageDelete: Event<typeof Events.MessageDelete> = {
|
export const messageDelete: Event<typeof Events.MessageDelete> = {
|
||||||
name: Events.MessageDelete,
|
name: Events.MessageDelete,
|
||||||
|
@ -62,4 +73,93 @@ export const messageUpdate: Event<typeof Events.MessageUpdate> = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default [messageDelete, messageUpdate];
|
export const messageCreate: Event<typeof Events.MessageCreate> = {
|
||||||
|
name: Events.MessageCreate,
|
||||||
|
execute: async (message: Message) => {
|
||||||
|
try {
|
||||||
|
if (message.author.bot || !message.guild) return;
|
||||||
|
|
||||||
|
const levelResult = await processMessage(message);
|
||||||
|
const advancementsChannelId = loadConfig().channels.advancements;
|
||||||
|
const advancementsChannel = message.guild?.channels.cache.get(
|
||||||
|
advancementsChannelId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!advancementsChannel?.isTextBased()) {
|
||||||
|
console.error(
|
||||||
|
'Advancements channel not found or is not a text channel',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (levelResult?.leveledUp) {
|
||||||
|
await advancementsChannel.send(
|
||||||
|
`🎉 Congratulations <@${message.author.id}>! You've leveled up to **Level ${levelResult.newLevel}**!`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const assignedRole = await checkAndAssignLevelRoles(
|
||||||
|
message.guild,
|
||||||
|
message.author.id,
|
||||||
|
levelResult.newLevel,
|
||||||
|
);
|
||||||
|
|
||||||
|
await processLevelUpAchievements(
|
||||||
|
message.author.id,
|
||||||
|
levelResult.newLevel,
|
||||||
|
message.guild,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (assignedRole) {
|
||||||
|
await advancementsChannel.send(
|
||||||
|
`<@${message.author.id}> You've earned the <@&${assignedRole}> role!`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const countingChannelId = loadConfig().channels.counting;
|
||||||
|
const countingChannel =
|
||||||
|
message.guild?.channels.cache.get(countingChannelId);
|
||||||
|
|
||||||
|
if (!countingChannel || message.channel.id !== countingChannelId) return;
|
||||||
|
if (!countingChannel.isTextBased()) {
|
||||||
|
console.error('Counting channel not found or is not a text channel');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await processCountingMessage(message);
|
||||||
|
|
||||||
|
if (result.isValid) {
|
||||||
|
await addCountingReactions(message, result.milestoneType || 'normal');
|
||||||
|
} else {
|
||||||
|
let errorMessage: string;
|
||||||
|
|
||||||
|
switch (result.reason) {
|
||||||
|
case 'not_a_number':
|
||||||
|
errorMessage = `${message.author}, that's not a valid number! The count has been reset. The next number should be **1**.`;
|
||||||
|
break;
|
||||||
|
case 'too_high':
|
||||||
|
errorMessage = `${message.author}, too high! The count was **${(result?.expectedCount ?? 0) - 1}** and the next number should have been **${result.expectedCount}**. The count has been reset.`;
|
||||||
|
break;
|
||||||
|
case 'too_low':
|
||||||
|
errorMessage = `${message.author}, too low! The count was **${(result?.expectedCount ?? 0) - 1}** and the next number should have been **${result.expectedCount}**. The count has been reset.`;
|
||||||
|
break;
|
||||||
|
case 'same_user':
|
||||||
|
errorMessage = `${message.author}, you can't count twice in a row! The count has been reset. The next number should be **1**.`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
errorMessage = `${message.author}, something went wrong with the count. The count has been reset. The next number should be **1**.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await resetCounting();
|
||||||
|
|
||||||
|
await countingChannel.send(errorMessage);
|
||||||
|
|
||||||
|
await message.react('❌');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling message create: ', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default [messageCreate, messageDelete, messageUpdate];
|
||||||
|
|
52
src/events/reactionEvents.ts
Normal file
52
src/events/reactionEvents.ts
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
import {
|
||||||
|
Events,
|
||||||
|
MessageReaction,
|
||||||
|
PartialMessageReaction,
|
||||||
|
User,
|
||||||
|
PartialUser,
|
||||||
|
} from 'discord.js';
|
||||||
|
|
||||||
|
import { Event } from '@/types/EventTypes.js';
|
||||||
|
import {
|
||||||
|
decrementUserReactionCount,
|
||||||
|
incrementUserReactionCount,
|
||||||
|
} from '@/db/db.js';
|
||||||
|
import { processReactionAchievements } from '@/util/achievementManager.js';
|
||||||
|
|
||||||
|
export const reactionAdd: Event<typeof Events.MessageReactionAdd> = {
|
||||||
|
name: Events.MessageReactionAdd,
|
||||||
|
execute: async (
|
||||||
|
reaction: MessageReaction | PartialMessageReaction,
|
||||||
|
user: User | PartialUser,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
if (user.bot || !reaction.message.guild) return;
|
||||||
|
|
||||||
|
await incrementUserReactionCount(user.id);
|
||||||
|
|
||||||
|
await processReactionAchievements(user.id, reaction.message.guild);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling reaction add:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reactionRemove: Event<typeof Events.MessageReactionRemove> = {
|
||||||
|
name: Events.MessageReactionRemove,
|
||||||
|
execute: async (
|
||||||
|
reaction: MessageReaction | PartialMessageReaction,
|
||||||
|
user: User | PartialUser,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
if (user.bot || !reaction.message.guild) return;
|
||||||
|
|
||||||
|
await decrementUserReactionCount(user.id);
|
||||||
|
|
||||||
|
await processReactionAchievements(user.id, reaction.message.guild, true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling reaction remove:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default [reactionAdd, reactionRemove];
|
|
@ -1,15 +1,29 @@
|
||||||
import { Client, Events } from 'discord.js';
|
import { Client, Events } from 'discord.js';
|
||||||
|
|
||||||
import { setMembers } from '../db/db.js';
|
import { ensureDbInitialized, setMembers } from '@/db/db.js';
|
||||||
import { loadConfig } from '../util/configLoader.js';
|
import { loadConfig } from '@/util/configLoader.js';
|
||||||
import { Event } from '../types/EventTypes.js';
|
import { Event } from '@/types/EventTypes.js';
|
||||||
|
import { scheduleFactOfTheDay } from '@/util/factManager.js';
|
||||||
|
import { scheduleGiveaways } from '@/util/giveaways/giveawayManager.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ensureRedisConnection,
|
||||||
|
setDiscordClient as setRedisDiscordClient,
|
||||||
|
} from '@/db/redis.js';
|
||||||
|
import { setDiscordClient as setDbDiscordClient } from '@/db/db.js';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: Events.ClientReady,
|
name: Events.ClientReady,
|
||||||
once: true,
|
once: true,
|
||||||
execute: async (client: Client) => {
|
execute: async (client: Client) => {
|
||||||
const config = loadConfig();
|
|
||||||
try {
|
try {
|
||||||
|
const config = loadConfig();
|
||||||
|
setRedisDiscordClient(client);
|
||||||
|
setDbDiscordClient(client);
|
||||||
|
|
||||||
|
await ensureDbInitialized();
|
||||||
|
await ensureRedisConnection();
|
||||||
|
|
||||||
const guild = client.guilds.cache.find(
|
const guild = client.guilds.cache.find(
|
||||||
(guilds) => guilds.id === config.guildId,
|
(guilds) => guilds.id === config.guildId,
|
||||||
);
|
);
|
||||||
|
@ -21,10 +35,13 @@ export default {
|
||||||
const members = await guild.members.fetch();
|
const members = await guild.members.fetch();
|
||||||
const nonBotMembers = members.filter((m) => !m.user.bot);
|
const nonBotMembers = members.filter((m) => !m.user.bot);
|
||||||
await setMembers(nonBotMembers);
|
await setMembers(nonBotMembers);
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to initialize members in database:', error);
|
await scheduleFactOfTheDay(client);
|
||||||
}
|
await scheduleGiveaways(client);
|
||||||
|
|
||||||
console.log(`Ready! Logged in as ${client.user?.tag}`);
|
console.log(`Ready! Logged in as ${client.user?.tag}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize the bot:', error);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
} as Event<typeof Events.ClientReady>;
|
} as Event<typeof Events.ClientReady>;
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { AuditLogEvent, Events, Role } from 'discord.js';
|
import { AuditLogEvent, Events, Role } from 'discord.js';
|
||||||
|
|
||||||
import { Event } from '../types/EventTypes.js';
|
import { Event } from '@/types/EventTypes.js';
|
||||||
import logAction from '../util/logging/logAction.js';
|
import logAction from '@/util/logging/logAction.js';
|
||||||
|
|
||||||
const convertRoleProperties = (role: Role) => ({
|
const convertRoleProperties = (role: Role) => ({
|
||||||
name: role.name,
|
name: role.name,
|
||||||
|
|
|
@ -1,9 +1,12 @@
|
||||||
import { Client, ClientOptions, Collection } from 'discord.js';
|
import { Client, ClientOptions, Collection } from 'discord.js';
|
||||||
import { Command } from '../types/CommandTypes.js';
|
import { Command } from '@/types/CommandTypes.js';
|
||||||
import { Config } from '../types/ConfigTypes.js';
|
import { Config } from '@/types/ConfigTypes.js';
|
||||||
import { deployCommands } from '../util/deployCommand.js';
|
import { deployCommands } from '@/util/deployCommand.js';
|
||||||
import { registerEvents } from '../util/eventLoader.js';
|
import { registerEvents } from '@/util/eventLoader.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extended client class that extends the default Client class
|
||||||
|
*/
|
||||||
export class ExtendedClient extends Client {
|
export class ExtendedClient extends Client {
|
||||||
public commands: Collection<string, Command>;
|
public commands: Collection<string, Command>;
|
||||||
private config: Config;
|
private config: Config;
|
||||||
|
|
|
@ -1,15 +1,30 @@
|
||||||
import {
|
import {
|
||||||
CommandInteraction,
|
ChatInputCommandInteraction,
|
||||||
SlashCommandBuilder,
|
SlashCommandBuilder,
|
||||||
SlashCommandOptionsOnlyBuilder,
|
SlashCommandOptionsOnlyBuilder,
|
||||||
|
SlashCommandSubcommandsOnlyBuilder,
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command interface for normal commands
|
||||||
|
*/
|
||||||
export interface Command {
|
export interface Command {
|
||||||
data: Omit<SlashCommandBuilder, 'addSubcommand' | 'addSubcommandGroup'>;
|
data: Omit<SlashCommandBuilder, 'addSubcommand' | 'addSubcommandGroup'>;
|
||||||
execute: (interaction: CommandInteraction) => Promise<void>;
|
execute: (interaction: ChatInputCommandInteraction) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command interface for options commands
|
||||||
|
*/
|
||||||
export interface OptionsCommand {
|
export interface OptionsCommand {
|
||||||
data: SlashCommandOptionsOnlyBuilder;
|
data: SlashCommandOptionsOnlyBuilder;
|
||||||
execute: (interaction: CommandInteraction) => Promise<void>;
|
execute: (interaction: ChatInputCommandInteraction) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command interface for subcommand commands
|
||||||
|
*/
|
||||||
|
export interface SubcommandCommand {
|
||||||
|
data: SlashCommandSubcommandsOnlyBuilder;
|
||||||
|
execute: (interaction: ChatInputCommandInteraction) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,14 +1,43 @@
|
||||||
|
/**
|
||||||
|
* Config interface for the bot
|
||||||
|
*/
|
||||||
export interface Config {
|
export interface Config {
|
||||||
token: string;
|
token: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
guildId: string;
|
guildId: string;
|
||||||
|
database: {
|
||||||
dbConnectionString: string;
|
dbConnectionString: string;
|
||||||
|
maxRetryAttempts: number;
|
||||||
|
retryDelay: number;
|
||||||
|
};
|
||||||
|
redis: {
|
||||||
redisConnectionString: string;
|
redisConnectionString: string;
|
||||||
|
retryAttempts: number;
|
||||||
|
initialRetryDelay: number;
|
||||||
|
};
|
||||||
channels: {
|
channels: {
|
||||||
welcome: string;
|
welcome: string;
|
||||||
logs: string;
|
logs: string;
|
||||||
|
counting: string;
|
||||||
|
factOfTheDay: string;
|
||||||
|
factApproval: string;
|
||||||
|
advancements: string;
|
||||||
};
|
};
|
||||||
roles: {
|
roles: {
|
||||||
joinRoles: string[];
|
joinRoles: string[];
|
||||||
|
levelRoles: {
|
||||||
|
level: number;
|
||||||
|
roleId: string;
|
||||||
|
}[];
|
||||||
|
staffRoles: {
|
||||||
|
name: string;
|
||||||
|
roleId: string;
|
||||||
|
}[];
|
||||||
|
factPingRole: string;
|
||||||
|
};
|
||||||
|
leveling: {
|
||||||
|
xpCooldown: number;
|
||||||
|
minXpAwarded: number;
|
||||||
|
maxXpAwarded: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
import { ClientEvents } from 'discord.js';
|
import { ClientEvents } from 'discord.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event interface for events
|
||||||
|
*/
|
||||||
export interface Event<K extends keyof ClientEvents> {
|
export interface Event<K extends keyof ClientEvents> {
|
||||||
name: K;
|
name: K;
|
||||||
once?: boolean;
|
once?: boolean;
|
||||||
|
|
115
src/util/achievementCardGenerator.ts
Normal file
115
src/util/achievementCardGenerator.ts
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
import Canvas, { GlobalFonts } from '@napi-rs/canvas';
|
||||||
|
import { AttachmentBuilder } from 'discord.js';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import * as schema from '@/db/schema.js';
|
||||||
|
import { drawMultilineText, roundRect } from './helpers.js';
|
||||||
|
|
||||||
|
const __dirname = path.resolve();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an achievement card for a user
|
||||||
|
* TODO: Make this look better
|
||||||
|
* @param achievement - The achievement to generate a card for
|
||||||
|
* @returns - The generated card as an AttachmentBuilder
|
||||||
|
*/
|
||||||
|
export async function generateAchievementCard(
|
||||||
|
achievement: schema.achievementDefinitionsTableTypes,
|
||||||
|
): Promise<AttachmentBuilder> {
|
||||||
|
GlobalFonts.registerFromPath(
|
||||||
|
path.join(__dirname, 'assets', 'fonts', 'Manrope-Bold.ttf'),
|
||||||
|
'Manrope Bold',
|
||||||
|
);
|
||||||
|
GlobalFonts.registerFromPath(
|
||||||
|
path.join(__dirname, 'assets', 'fonts', 'Manrope-Regular.ttf'),
|
||||||
|
'Manrope',
|
||||||
|
);
|
||||||
|
|
||||||
|
const width = 600;
|
||||||
|
const height = 180;
|
||||||
|
const canvas = Canvas.createCanvas(width, height);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
const gradient = ctx.createLinearGradient(0, 0, width, 0);
|
||||||
|
gradient.addColorStop(0, '#5865F2');
|
||||||
|
gradient.addColorStop(1, '#EB459E');
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
roundRect({ ctx, x: 0, y: 0, width, height, radius: 16, fill: true });
|
||||||
|
|
||||||
|
ctx.lineWidth = 4;
|
||||||
|
ctx.strokeStyle = '#FFFFFF';
|
||||||
|
roundRect({
|
||||||
|
ctx,
|
||||||
|
x: 2,
|
||||||
|
y: 2,
|
||||||
|
width: width - 4,
|
||||||
|
height: height - 4,
|
||||||
|
radius: 16,
|
||||||
|
fill: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const padding = 40;
|
||||||
|
const iconSize = 72;
|
||||||
|
const iconX = padding;
|
||||||
|
const iconY = height / 2 - iconSize / 2;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const iconImage = await Canvas.loadImage(
|
||||||
|
achievement.imageUrl ||
|
||||||
|
path.join(__dirname, 'assets', 'images', 'trophy.png'),
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(
|
||||||
|
iconX + iconSize / 2,
|
||||||
|
iconY + iconSize / 2,
|
||||||
|
iconSize / 2,
|
||||||
|
0,
|
||||||
|
Math.PI * 2,
|
||||||
|
);
|
||||||
|
ctx.clip();
|
||||||
|
ctx.drawImage(iconImage, iconX, iconY, iconSize, iconSize);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(
|
||||||
|
iconX + iconSize / 2,
|
||||||
|
iconY + iconSize / 2,
|
||||||
|
iconSize / 2 + 4,
|
||||||
|
0,
|
||||||
|
Math.PI * 2,
|
||||||
|
);
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.strokeStyle = '#FFFFFF';
|
||||||
|
ctx.stroke();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading icon:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const textX = iconX + iconSize + 24;
|
||||||
|
const titleY = 60;
|
||||||
|
const nameY = titleY + 35;
|
||||||
|
const descY = nameY + 34;
|
||||||
|
|
||||||
|
ctx.fillStyle = '#FFFFFF';
|
||||||
|
|
||||||
|
ctx.font = '22px "Manrope Bold"';
|
||||||
|
ctx.fillText('Achievement Unlocked!', textX, titleY);
|
||||||
|
|
||||||
|
ctx.font = '32px "Manrope Bold"';
|
||||||
|
ctx.fillText(achievement.name, textX, nameY);
|
||||||
|
|
||||||
|
ctx.font = '20px "Manrope"';
|
||||||
|
drawMultilineText(
|
||||||
|
ctx,
|
||||||
|
achievement.description,
|
||||||
|
textX,
|
||||||
|
descY,
|
||||||
|
width - textX - 32,
|
||||||
|
24,
|
||||||
|
);
|
||||||
|
|
||||||
|
const buffer = canvas.toBuffer('image/png');
|
||||||
|
return new AttachmentBuilder(buffer, { name: 'achievement.png' });
|
||||||
|
}
|
303
src/util/achievementManager.ts
Normal file
303
src/util/achievementManager.ts
Normal file
|
@ -0,0 +1,303 @@
|
||||||
|
import {
|
||||||
|
Message,
|
||||||
|
Client,
|
||||||
|
EmbedBuilder,
|
||||||
|
GuildMember,
|
||||||
|
TextChannel,
|
||||||
|
Guild,
|
||||||
|
} from 'discord.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
addXpToUser,
|
||||||
|
awardAchievement,
|
||||||
|
getAllAchievements,
|
||||||
|
getUserAchievements,
|
||||||
|
getUserLevel,
|
||||||
|
getUserReactionCount,
|
||||||
|
updateAchievementProgress,
|
||||||
|
} from '@/db/db.js';
|
||||||
|
import * as schema from '@/db/schema.js';
|
||||||
|
import { loadConfig } from './configLoader.js';
|
||||||
|
import { generateAchievementCard } from './achievementCardGenerator.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check and process achievements for a user based on a message
|
||||||
|
* @param message - The message that triggered the check
|
||||||
|
*/
|
||||||
|
export async function processMessageAchievements(
|
||||||
|
message: Message,
|
||||||
|
): Promise<void> {
|
||||||
|
if (message.author.bot) return;
|
||||||
|
|
||||||
|
const userData = await getUserLevel(message.author.id);
|
||||||
|
const allAchievements = await getAllAchievements();
|
||||||
|
|
||||||
|
const messageAchievements = allAchievements.filter(
|
||||||
|
(a) => a.requirementType === 'message_count',
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const achievement of messageAchievements) {
|
||||||
|
const progress = Math.min(
|
||||||
|
100,
|
||||||
|
(userData.messagesSent / achievement.threshold) * 100,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (progress >= 100) {
|
||||||
|
const userAchievements = await getUserAchievements(message.author.id);
|
||||||
|
const existingAchievement = userAchievements.find(
|
||||||
|
(a) => a.achievementId === achievement.id && a.earnedAt !== null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existingAchievement) {
|
||||||
|
const awarded = await awardAchievement(
|
||||||
|
message.author.id,
|
||||||
|
achievement.id,
|
||||||
|
);
|
||||||
|
if (awarded) {
|
||||||
|
await announceAchievement(
|
||||||
|
message.guild!,
|
||||||
|
message.author.id,
|
||||||
|
achievement,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await updateAchievementProgress(
|
||||||
|
message.author.id,
|
||||||
|
achievement.id,
|
||||||
|
progress,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const levelAchievements = allAchievements.filter(
|
||||||
|
(a) => a.requirementType === 'level',
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const achievement of levelAchievements) {
|
||||||
|
const progress = Math.min(
|
||||||
|
100,
|
||||||
|
(userData.level / achievement.threshold) * 100,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (progress >= 100) {
|
||||||
|
const userAchievements = await getUserAchievements(message.author.id);
|
||||||
|
const existingAchievement = userAchievements.find(
|
||||||
|
(a) => a.achievementId === achievement.id && a.earnedAt !== null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existingAchievement) {
|
||||||
|
const awarded = await awardAchievement(
|
||||||
|
message.author.id,
|
||||||
|
achievement.id,
|
||||||
|
);
|
||||||
|
if (awarded) {
|
||||||
|
await announceAchievement(
|
||||||
|
message.guild!,
|
||||||
|
message.author.id,
|
||||||
|
achievement,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await updateAchievementProgress(
|
||||||
|
message.author.id,
|
||||||
|
achievement.id,
|
||||||
|
progress,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check achievements for level-ups
|
||||||
|
* @param memberId - Member ID who leveled up
|
||||||
|
* @param newLevel - New level value
|
||||||
|
* @guild - Guild instance
|
||||||
|
*/
|
||||||
|
export async function processLevelUpAchievements(
|
||||||
|
memberId: string,
|
||||||
|
newLevel: number,
|
||||||
|
guild: Guild,
|
||||||
|
): Promise<void> {
|
||||||
|
const allAchievements = await getAllAchievements();
|
||||||
|
|
||||||
|
const levelAchievements = allAchievements.filter(
|
||||||
|
(a) => a.requirementType === 'level',
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const achievement of levelAchievements) {
|
||||||
|
const progress = Math.min(100, (newLevel / achievement.threshold) * 100);
|
||||||
|
|
||||||
|
if (progress >= 100) {
|
||||||
|
const userAchievements = await getUserAchievements(memberId);
|
||||||
|
const existingAchievement = userAchievements.find(
|
||||||
|
(a) => a.achievementId === achievement.id && a.earnedAt !== null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existingAchievement) {
|
||||||
|
const awarded = await awardAchievement(memberId, achievement.id);
|
||||||
|
if (awarded) {
|
||||||
|
await announceAchievement(guild, memberId, achievement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await updateAchievementProgress(memberId, achievement.id, progress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process achievements for command usage
|
||||||
|
* @param userId - User ID who used the command
|
||||||
|
* @param commandName - Name of the command
|
||||||
|
* @param client - Guild instance
|
||||||
|
*/
|
||||||
|
export async function processCommandAchievements(
|
||||||
|
userId: string,
|
||||||
|
commandName: string,
|
||||||
|
guild: Guild,
|
||||||
|
): Promise<void> {
|
||||||
|
const allAchievements = await getAllAchievements();
|
||||||
|
|
||||||
|
const commandAchievements = allAchievements.filter(
|
||||||
|
(a) =>
|
||||||
|
a.requirementType === 'command_usage' &&
|
||||||
|
a.requirement &&
|
||||||
|
(a.requirement as any).command === commandName,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const achievement of commandAchievements) {
|
||||||
|
const userAchievements = await getUserAchievements(userId);
|
||||||
|
const existingAchievement = userAchievements.find(
|
||||||
|
(a) => a.achievementId === achievement.id && a.earnedAt !== null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existingAchievement) {
|
||||||
|
const awarded = await awardAchievement(userId, achievement.id);
|
||||||
|
if (awarded) {
|
||||||
|
await announceAchievement(guild, userId, achievement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process achievements for reaction events (add or remove)
|
||||||
|
* @param userId - User ID who added/removed the reaction
|
||||||
|
* @param guild - Guild instance
|
||||||
|
* @param isRemoval - Whether this is a reaction removal (true) or addition (false)
|
||||||
|
*/
|
||||||
|
export async function processReactionAchievements(
|
||||||
|
userId: string,
|
||||||
|
guild: Guild,
|
||||||
|
isRemoval: boolean = false,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const member = await guild.members.fetch(userId);
|
||||||
|
if (member.user.bot) return;
|
||||||
|
|
||||||
|
const allAchievements = await getAllAchievements();
|
||||||
|
|
||||||
|
const reactionAchievements = allAchievements.filter(
|
||||||
|
(a) => a.requirementType === 'reactions',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (reactionAchievements.length === 0) return;
|
||||||
|
|
||||||
|
const reactionCount = await getUserReactionCount(userId);
|
||||||
|
|
||||||
|
for (const achievement of reactionAchievements) {
|
||||||
|
const progress = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(100, (reactionCount / achievement.threshold) * 100),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (progress >= 100 && !isRemoval) {
|
||||||
|
const userAchievements = await getUserAchievements(userId);
|
||||||
|
const existingAchievement = userAchievements.find(
|
||||||
|
(a) =>
|
||||||
|
a.achievementId === achievement.id &&
|
||||||
|
a.earnedAt !== null &&
|
||||||
|
a.earnedAt !== undefined &&
|
||||||
|
new Date(a.earnedAt).getTime() > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existingAchievement) {
|
||||||
|
const awarded = await awardAchievement(userId, achievement.id);
|
||||||
|
if (awarded) {
|
||||||
|
await announceAchievement(guild, userId, achievement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateAchievementProgress(userId, achievement.id, progress);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error processing reaction achievements:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Announce a newly earned achievement
|
||||||
|
* @param guild - Guild instance
|
||||||
|
* @param userId - ID of the user who earned the achievement
|
||||||
|
* @param achievement - Achievement definition
|
||||||
|
*/
|
||||||
|
export async function announceAchievement(
|
||||||
|
guild: Guild,
|
||||||
|
userId: string,
|
||||||
|
achievement: schema.achievementDefinitionsTableTypes,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
if (!guild) {
|
||||||
|
console.error(`Guild ${guild} not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = await guild.members.fetch(userId);
|
||||||
|
if (!member) {
|
||||||
|
console.warn(`Member ${userId} not found in guild`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const achievementCard = await generateAchievementCard(achievement);
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setColor(0xffd700)
|
||||||
|
.setDescription(
|
||||||
|
`**${member.user.username}** just unlocked the achievement: **${achievement.name}**! 🎉`,
|
||||||
|
)
|
||||||
|
.setImage('attachment://achievement.png')
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
const advChannel = guild.channels.cache.get(config.channels.advancements);
|
||||||
|
if (advChannel?.isTextBased()) {
|
||||||
|
await (advChannel as TextChannel).send({
|
||||||
|
content: `Congratulations <@${userId}>!`,
|
||||||
|
embeds: [embed],
|
||||||
|
files: [achievementCard],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (achievement.rewardType === 'xp' && achievement.rewardValue) {
|
||||||
|
const xpAmount = parseInt(achievement.rewardValue);
|
||||||
|
if (!isNaN(xpAmount)) {
|
||||||
|
await addXpToUser(userId, xpAmount);
|
||||||
|
}
|
||||||
|
} else if (achievement.rewardType === 'role' && achievement.rewardValue) {
|
||||||
|
try {
|
||||||
|
await member.roles.add(achievement.rewardValue);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`Failed to add role ${achievement.rewardValue} to user ${userId}`,
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error announcing achievement:', error);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,7 +1,12 @@
|
||||||
import { Config } from '../types/ConfigTypes.js';
|
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { Config } from '@/types/ConfigTypes.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the config file from the root directory
|
||||||
|
* @returns - The loaded config object
|
||||||
|
*/
|
||||||
export function loadConfig(): Config {
|
export function loadConfig(): Config {
|
||||||
try {
|
try {
|
||||||
const configPath = path.join(process.cwd(), './config.json');
|
const configPath = path.join(process.cwd(), './config.json');
|
||||||
|
|
191
src/util/countingManager.ts
Normal file
191
src/util/countingManager.ts
Normal file
|
@ -0,0 +1,191 @@
|
||||||
|
import { Message } from 'discord.js';
|
||||||
|
|
||||||
|
import { getJson, setJson } from '@/db/redis.js';
|
||||||
|
|
||||||
|
interface CountingData {
|
||||||
|
currentCount: number;
|
||||||
|
lastUserId: string | null;
|
||||||
|
highestCount: number;
|
||||||
|
totalCorrect: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MILESTONE_REACTIONS = {
|
||||||
|
normal: '✅',
|
||||||
|
multiples25: '✨',
|
||||||
|
multiples50: '⭐',
|
||||||
|
multiples100: '🎉',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the counting data if it doesn't exist
|
||||||
|
* @returns - The initialized counting data
|
||||||
|
*/
|
||||||
|
export async function initializeCountingData(): Promise<CountingData> {
|
||||||
|
const exists = await getJson<CountingData>('counting');
|
||||||
|
if (exists) return exists;
|
||||||
|
|
||||||
|
const initialData: CountingData = {
|
||||||
|
currentCount: 0,
|
||||||
|
lastUserId: null,
|
||||||
|
highestCount: 0,
|
||||||
|
totalCorrect: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
await setJson<CountingData>('counting', initialData);
|
||||||
|
return initialData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the current counting data
|
||||||
|
* @returns - The current counting data
|
||||||
|
*/
|
||||||
|
export async function getCountingData(): Promise<CountingData> {
|
||||||
|
const data = await getJson<CountingData>('counting');
|
||||||
|
if (!data) {
|
||||||
|
return initializeCountingData();
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the counting data with new data
|
||||||
|
* @param data - The data to update the counting data with
|
||||||
|
*/
|
||||||
|
export async function updateCountingData(
|
||||||
|
data: Partial<CountingData>,
|
||||||
|
): Promise<void> {
|
||||||
|
const currentData = await getCountingData();
|
||||||
|
const updatedData = { ...currentData, ...data };
|
||||||
|
await setJson<CountingData>('counting', updatedData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the counting data to the initial state
|
||||||
|
* @returns - The current count
|
||||||
|
*/
|
||||||
|
export async function resetCounting(): Promise<void> {
|
||||||
|
await updateCountingData({
|
||||||
|
currentCount: 0,
|
||||||
|
lastUserId: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes a counting message to determine if it is valid
|
||||||
|
* @param message - The message to process
|
||||||
|
* @returns - An object with information about the message
|
||||||
|
*/
|
||||||
|
export async function processCountingMessage(message: Message): Promise<{
|
||||||
|
isValid: boolean;
|
||||||
|
expectedCount?: number;
|
||||||
|
isMilestone?: boolean;
|
||||||
|
milestoneType?: keyof typeof MILESTONE_REACTIONS;
|
||||||
|
reason?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const countingData = await getCountingData();
|
||||||
|
|
||||||
|
const content = message.content.trim();
|
||||||
|
const count = Number(content);
|
||||||
|
|
||||||
|
if (isNaN(count) || !Number.isInteger(count)) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
expectedCount: countingData.currentCount + 1,
|
||||||
|
reason: 'not_a_number',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedCount = countingData.currentCount + 1;
|
||||||
|
if (count !== expectedCount) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
expectedCount,
|
||||||
|
reason: count > expectedCount ? 'too_high' : 'too_low',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (countingData.lastUserId === message.author.id) {
|
||||||
|
return { isValid: false, expectedCount, reason: 'same_user' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCount = countingData.currentCount + 1;
|
||||||
|
const newHighestCount = Math.max(newCount, countingData.highestCount);
|
||||||
|
|
||||||
|
await updateCountingData({
|
||||||
|
currentCount: newCount,
|
||||||
|
lastUserId: message.author.id,
|
||||||
|
highestCount: newHighestCount,
|
||||||
|
totalCorrect: countingData.totalCorrect + 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
let isMilestone = false;
|
||||||
|
let milestoneType: keyof typeof MILESTONE_REACTIONS = 'normal';
|
||||||
|
|
||||||
|
if (newCount % 100 === 0) {
|
||||||
|
isMilestone = true;
|
||||||
|
milestoneType = 'multiples100';
|
||||||
|
} else if (newCount % 50 === 0) {
|
||||||
|
isMilestone = true;
|
||||||
|
milestoneType = 'multiples50';
|
||||||
|
} else if (newCount % 25 === 0) {
|
||||||
|
isMilestone = true;
|
||||||
|
milestoneType = 'multiples25';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: true,
|
||||||
|
expectedCount: newCount + 1,
|
||||||
|
isMilestone,
|
||||||
|
milestoneType,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error processing counting message:', error);
|
||||||
|
return { isValid: false, reason: 'error' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds counting reactions to a message based on the milestone type
|
||||||
|
* @param message - The message to add counting reactions to
|
||||||
|
* @param milestoneType - The type of milestone to add reactions for
|
||||||
|
*/
|
||||||
|
export async function addCountingReactions(
|
||||||
|
message: Message,
|
||||||
|
milestoneType: keyof typeof MILESTONE_REACTIONS,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await message.react(MILESTONE_REACTIONS[milestoneType]);
|
||||||
|
|
||||||
|
if (milestoneType === 'multiples100') {
|
||||||
|
await message.react('💯');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding counting reactions:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the current counting status
|
||||||
|
* @returns - A string with the current counting status
|
||||||
|
*/
|
||||||
|
export async function getCountingStatus(): Promise<string> {
|
||||||
|
const data = await getCountingData();
|
||||||
|
return `Current count: ${data.currentCount}\nHighest count ever: ${data.highestCount}\nTotal correct counts: ${data.totalCorrect}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the current count to a specific number
|
||||||
|
* @param count - The number to set as the current count
|
||||||
|
*/
|
||||||
|
export async function setCount(count: number): Promise<void> {
|
||||||
|
if (!Number.isInteger(count) || count < 0) {
|
||||||
|
throw new Error('Count must be a non-negative integer.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateCountingData({
|
||||||
|
currentCount: count,
|
||||||
|
lastUserId: null,
|
||||||
|
});
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
import { REST, Routes } from 'discord.js';
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { REST, Routes } from 'discord.js';
|
||||||
|
|
||||||
import { loadConfig } from './configLoader.js';
|
import { loadConfig } from './configLoader.js';
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
|
@ -11,6 +12,11 @@ const commandsPath = path.join(__dirname, 'target', 'commands');
|
||||||
|
|
||||||
const rest = new REST({ version: '10' }).setToken(token);
|
const rest = new REST({ version: '10' }).setToken(token);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all files in the command directory and its subdirectories
|
||||||
|
* @param directory - The directory to get files from
|
||||||
|
* @returns - An array of file paths
|
||||||
|
*/
|
||||||
const getFilesRecursively = (directory: string): string[] => {
|
const getFilesRecursively = (directory: string): string[] => {
|
||||||
const files: string[] = [];
|
const files: string[] = [];
|
||||||
const filesInDirectory = fs.readdirSync(directory);
|
const filesInDirectory = fs.readdirSync(directory);
|
||||||
|
@ -30,15 +36,21 @@ const getFilesRecursively = (directory: string): string[] => {
|
||||||
|
|
||||||
const commandFiles = getFilesRecursively(commandsPath);
|
const commandFiles = getFilesRecursively(commandsPath);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers all commands in the command directory with the Discord API
|
||||||
|
* @returns - An array of valid command objects
|
||||||
|
*/
|
||||||
export const deployCommands = async () => {
|
export const deployCommands = async () => {
|
||||||
try {
|
try {
|
||||||
console.log(
|
console.log(
|
||||||
`Started refreshing ${commandFiles.length} application (/) commands...`,
|
`Started refreshing ${commandFiles.length} application (/) commands...`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const existingCommands = (await rest.get(
|
console.log('Undeploying all existing commands...');
|
||||||
Routes.applicationGuildCommands(clientId, guildId),
|
await rest.put(Routes.applicationGuildCommands(clientId, guildId), {
|
||||||
)) as any[];
|
body: [],
|
||||||
|
});
|
||||||
|
console.log('Successfully undeployed all commands');
|
||||||
|
|
||||||
const commands = commandFiles.map(async (file) => {
|
const commands = commandFiles.map(async (file) => {
|
||||||
const commandModule = await import(`file://${file}`);
|
const commandModule = await import(`file://${file}`);
|
||||||
|
@ -64,18 +76,6 @@ export const deployCommands = async () => {
|
||||||
|
|
||||||
const apiCommands = validCommands.map((command) => command.data.toJSON());
|
const apiCommands = validCommands.map((command) => command.data.toJSON());
|
||||||
|
|
||||||
const commandsToRemove = existingCommands.filter(
|
|
||||||
(existingCmd) =>
|
|
||||||
!apiCommands.some((newCmd) => newCmd.name === existingCmd.name),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const cmdToRemove of commandsToRemove) {
|
|
||||||
await rest.delete(
|
|
||||||
Routes.applicationGuildCommand(clientId, guildId, cmdToRemove.id),
|
|
||||||
);
|
|
||||||
console.log(`Removed command: ${cmdToRemove.name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: any = await rest.put(
|
const data: any = await rest.put(
|
||||||
Routes.applicationGuildCommands(clientId, guildId),
|
Routes.applicationGuildCommands(clientId, guildId),
|
||||||
{ body: apiCommands },
|
{ body: apiCommands },
|
||||||
|
|
|
@ -1,12 +1,15 @@
|
||||||
import { Client } from 'discord.js';
|
import { Client } from 'discord.js';
|
||||||
import { readdirSync } from 'fs';
|
import { readdirSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join, dirname } from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { dirname } from 'path';
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers all event handlers in the events directory
|
||||||
|
* @param client - The Discord client
|
||||||
|
*/
|
||||||
export async function registerEvents(client: Client): Promise<void> {
|
export async function registerEvents(client: Client): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const eventsPath = join(__dirname, '..', 'events');
|
const eventsPath = join(__dirname, '..', 'events');
|
||||||
|
|
89
src/util/factManager.ts
Normal file
89
src/util/factManager.ts
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
import { EmbedBuilder, Client } from 'discord.js';
|
||||||
|
|
||||||
|
import { getRandomUnusedFact, markFactAsUsed } from '@/db/db.js';
|
||||||
|
import { loadConfig } from './configLoader.js';
|
||||||
|
|
||||||
|
let isFactScheduled = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule the fact of the day to be posted daily
|
||||||
|
* @param client - The Discord client
|
||||||
|
*/
|
||||||
|
export async function scheduleFactOfTheDay(client: Client): Promise<void> {
|
||||||
|
if (isFactScheduled) {
|
||||||
|
console.log(
|
||||||
|
'Fact of the day already scheduled, skipping duplicate schedule',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
isFactScheduled = true;
|
||||||
|
const now = new Date();
|
||||||
|
const tomorrow = new Date();
|
||||||
|
tomorrow.setDate(now.getDate() + 1);
|
||||||
|
tomorrow.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const timeUntilMidnight = tomorrow.getTime() - now.getTime();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
postFactOfTheDay(client);
|
||||||
|
isFactScheduled = false;
|
||||||
|
scheduleFactOfTheDay(client);
|
||||||
|
}, timeUntilMidnight);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Next fact of the day scheduled in ${Math.floor(timeUntilMidnight / 1000 / 60)} minutes`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error scheduling fact of the day:', error);
|
||||||
|
isFactScheduled = false;
|
||||||
|
setTimeout(() => scheduleFactOfTheDay(client), 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post the fact of the day to the configured channel
|
||||||
|
* @param client - The Discord client
|
||||||
|
*/
|
||||||
|
export async function postFactOfTheDay(client: Client): Promise<void> {
|
||||||
|
try {
|
||||||
|
const config = loadConfig();
|
||||||
|
const guild = client.guilds.cache.get(config.guildId);
|
||||||
|
|
||||||
|
if (!guild) {
|
||||||
|
console.error('Guild not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const factChannel = guild.channels.cache.get(config.channels.factOfTheDay);
|
||||||
|
if (!factChannel?.isTextBased()) {
|
||||||
|
console.error('Fact channel not found or is not a text channel');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fact = await getRandomUnusedFact();
|
||||||
|
if (!fact) {
|
||||||
|
console.error('No facts available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle('🌟 Fact of the Day 🌟')
|
||||||
|
.setDescription(fact.content)
|
||||||
|
.setColor(0xffaa00)
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
if (fact.source) {
|
||||||
|
embed.setFooter({ text: `Source: ${fact.source}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
await factChannel.send({
|
||||||
|
content: `<@&${config.roles.factPingRole}>`,
|
||||||
|
embeds: [embed],
|
||||||
|
});
|
||||||
|
await markFactAsUsed(fact.id!);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error posting fact of the day:', error);
|
||||||
|
}
|
||||||
|
}
|
375
src/util/giveaways/builder.ts
Normal file
375
src/util/giveaways/builder.ts
Normal file
|
@ -0,0 +1,375 @@
|
||||||
|
import {
|
||||||
|
ActionRowBuilder,
|
||||||
|
ButtonBuilder,
|
||||||
|
ButtonInteraction,
|
||||||
|
ButtonStyle,
|
||||||
|
ChatInputCommandInteraction,
|
||||||
|
EmbedBuilder,
|
||||||
|
} from 'discord.js';
|
||||||
|
|
||||||
|
import { GiveawaySession } from './types.js';
|
||||||
|
import { DEFAULT_REQUIRE_ALL, DEFAULT_WINNER_COUNT } from './constants.js';
|
||||||
|
import { getSession, saveSession } from './utils.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the start of the giveaway builder.
|
||||||
|
* @param interaction The interaction object from the command or button click.
|
||||||
|
*/
|
||||||
|
export async function startGiveawayBuilder(
|
||||||
|
interaction: ChatInputCommandInteraction | ButtonInteraction,
|
||||||
|
): Promise<void> {
|
||||||
|
await interaction.deferReply({ flags: ['Ephemeral'] });
|
||||||
|
|
||||||
|
const session: GiveawaySession = {
|
||||||
|
step: 1,
|
||||||
|
winnerCount: DEFAULT_WINNER_COUNT,
|
||||||
|
requirements: {
|
||||||
|
requireAll: DEFAULT_REQUIRE_ALL,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await saveSession(interaction.user.id, session);
|
||||||
|
await showBuilderStep(interaction, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the display of the current step in the giveaway builder.
|
||||||
|
* @param interaction The interaction object from the command or button click.
|
||||||
|
* @param session The current giveaway session.
|
||||||
|
*/
|
||||||
|
export async function showBuilderStep(
|
||||||
|
interaction: any,
|
||||||
|
session: GiveawaySession,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!interaction.isCommand() && interaction.responded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let embed: EmbedBuilder;
|
||||||
|
const components: ActionRowBuilder<ButtonBuilder>[] = [];
|
||||||
|
|
||||||
|
switch (session.step) {
|
||||||
|
case 1:
|
||||||
|
embed = createStep1Embed(session);
|
||||||
|
components.push(createStep1Buttons(session));
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
embed = createStep2Embed(session);
|
||||||
|
components.push(...createStep2Buttons(session));
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
embed = createStep3Embed(session);
|
||||||
|
components.push(...createStep3Buttons(session));
|
||||||
|
break;
|
||||||
|
case 4:
|
||||||
|
embed = createStep4Embed(session);
|
||||||
|
components.push(...createStep4Buttons());
|
||||||
|
break;
|
||||||
|
case 5:
|
||||||
|
embed = createStep5Embed(session);
|
||||||
|
components.push(...createStep5Buttons());
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
embed = new EmbedBuilder()
|
||||||
|
.setTitle('🎉 Giveaway Creation')
|
||||||
|
.setDescription('Setting up your giveaway...')
|
||||||
|
.setColor(0x3498db);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interaction.replied || interaction.deferred) {
|
||||||
|
await interaction.editReply({ embeds: [embed], components });
|
||||||
|
} else {
|
||||||
|
await interaction.update({ embeds: [embed], components });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in showBuilderStep:', error);
|
||||||
|
if (!interaction.replied) {
|
||||||
|
try {
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'There was an error updating the giveaway builder.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
} catch (replyError) {
|
||||||
|
console.error('Failed to send error reply:', replyError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the next step in the giveaway builder.
|
||||||
|
* @param interaction The interaction object from the button click.
|
||||||
|
*/
|
||||||
|
export async function nextBuilderStep(
|
||||||
|
interaction: ButtonInteraction,
|
||||||
|
): Promise<void> {
|
||||||
|
const session = await getSession(interaction.user.id);
|
||||||
|
if (!session) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'Your giveaway session has expired. Please start over.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.step === 1) {
|
||||||
|
if (!session.prize || !session.endTime) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'Please set both prize and duration before continuing.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(session.endTime instanceof Date)) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'Invalid duration setting. Please set the duration again.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session.step = Math.min(session.step + 1, 5);
|
||||||
|
await saveSession(interaction.user.id, session);
|
||||||
|
await showBuilderStep(interaction, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the previous step in the giveaway builder.
|
||||||
|
* @param interaction The interaction object from the button click.
|
||||||
|
*/
|
||||||
|
export async function previousBuilderStep(
|
||||||
|
interaction: ButtonInteraction,
|
||||||
|
): Promise<void> {
|
||||||
|
const session = await getSession(interaction.user.id);
|
||||||
|
if (!session) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'Your giveaway session has expired. Please start over.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.step = Math.max(session.step - 1, 1);
|
||||||
|
await saveSession(interaction.user.id, session);
|
||||||
|
await showBuilderStep(interaction, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStep1Embed(session: GiveawaySession): EmbedBuilder {
|
||||||
|
const endTimeValue =
|
||||||
|
session.endTime instanceof Date
|
||||||
|
? `${session.duration} (ends <t:${Math.floor(session.endTime.getTime() / 1000)}:R>)`
|
||||||
|
: 'Not set';
|
||||||
|
|
||||||
|
return new EmbedBuilder()
|
||||||
|
.setTitle(' Giveaway Creation - Step 1/5')
|
||||||
|
.setDescription('Set the basic details for your giveaway.')
|
||||||
|
.setColor(0x3498db)
|
||||||
|
.addFields([
|
||||||
|
{ name: 'Prize', value: session.prize || 'Not set', inline: true },
|
||||||
|
{ name: 'Duration', value: endTimeValue, inline: true },
|
||||||
|
{ name: 'Winners', value: session.winnerCount.toString(), inline: true },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStep1Buttons(
|
||||||
|
session: GiveawaySession,
|
||||||
|
): ActionRowBuilder<ButtonBuilder> {
|
||||||
|
return new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId('giveaway_set_prize')
|
||||||
|
.setLabel('Set Prize')
|
||||||
|
.setStyle(ButtonStyle.Primary),
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId('giveaway_set_duration')
|
||||||
|
.setLabel('Set Duration')
|
||||||
|
.setStyle(ButtonStyle.Primary),
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId('giveaway_set_winners')
|
||||||
|
.setLabel('Set Winners')
|
||||||
|
.setStyle(ButtonStyle.Primary),
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId('giveaway_next')
|
||||||
|
.setLabel('Next Step')
|
||||||
|
.setStyle(ButtonStyle.Success)
|
||||||
|
.setDisabled(!session.prize || !session.endTime),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStep2Embed(session: GiveawaySession): EmbedBuilder {
|
||||||
|
const requirementsList = [];
|
||||||
|
if (session.requirements?.level) {
|
||||||
|
requirementsList.push(`• Level ${session.requirements.level}+`);
|
||||||
|
}
|
||||||
|
if (session.requirements?.roleId) {
|
||||||
|
requirementsList.push(`• Role <@&${session.requirements.roleId}>`);
|
||||||
|
}
|
||||||
|
if (session.requirements?.messageCount) {
|
||||||
|
requirementsList.push(`• ${session.requirements.messageCount}+ messages`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requirementsText = requirementsList.length
|
||||||
|
? `${session.requirements.requireAll ? 'ALL requirements must be met' : 'ANY ONE requirement must be met'}\n${requirementsList.join('\n')}`
|
||||||
|
: 'No requirements set';
|
||||||
|
|
||||||
|
return new EmbedBuilder()
|
||||||
|
.setTitle('🎉 Giveaway Creation - Step 2/5')
|
||||||
|
.setDescription('Set entry requirements for your giveaway (optional).')
|
||||||
|
.setColor(0x3498db)
|
||||||
|
.addFields([
|
||||||
|
{ name: 'Prize', value: session.prize || 'Not set' },
|
||||||
|
{ name: 'Requirements', value: requirementsText },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStep2Buttons(
|
||||||
|
session: GiveawaySession,
|
||||||
|
): ActionRowBuilder<ButtonBuilder>[] {
|
||||||
|
return [
|
||||||
|
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId('giveaway_set_requirements')
|
||||||
|
.setLabel('Set Requirements')
|
||||||
|
.setStyle(ButtonStyle.Primary),
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId('giveaway_toggle_logic')
|
||||||
|
.setLabel(
|
||||||
|
session.requirements.requireAll ? 'Require ANY' : 'Require ALL',
|
||||||
|
)
|
||||||
|
.setStyle(ButtonStyle.Secondary),
|
||||||
|
),
|
||||||
|
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId('giveaway_previous')
|
||||||
|
.setLabel('Previous Step')
|
||||||
|
.setStyle(ButtonStyle.Secondary),
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId('giveaway_next')
|
||||||
|
.setLabel('Next Step')
|
||||||
|
.setStyle(ButtonStyle.Success),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStep3Embed(session: GiveawaySession): EmbedBuilder {
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle('🎉 Giveaway Creation - Step 3/5')
|
||||||
|
.setDescription('Select Giveaway Channel (optional).')
|
||||||
|
.setColor(0x3498db)
|
||||||
|
.addFields([
|
||||||
|
{
|
||||||
|
name: 'Channel',
|
||||||
|
value: session.channelId
|
||||||
|
? `<#${session.channelId}>`
|
||||||
|
: 'Current Channel',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStep3Buttons(
|
||||||
|
session: GiveawaySession,
|
||||||
|
): ActionRowBuilder<ButtonBuilder>[] {
|
||||||
|
return [
|
||||||
|
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId('giveaway_set_channel')
|
||||||
|
.setLabel(session.channelId ? 'Change Channel' : 'Set Channel')
|
||||||
|
.setStyle(ButtonStyle.Primary),
|
||||||
|
),
|
||||||
|
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId('giveaway_previous')
|
||||||
|
.setLabel('Previous Step')
|
||||||
|
.setStyle(ButtonStyle.Secondary),
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId('giveaway_next')
|
||||||
|
.setLabel('Next Step')
|
||||||
|
.setStyle(ButtonStyle.Success),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStep4Embed(session: GiveawaySession): EmbedBuilder {
|
||||||
|
const bonusEntries = session.bonusEntries || {};
|
||||||
|
|
||||||
|
const rolesText =
|
||||||
|
bonusEntries.roles?.map((r) => `<@&${r.id}>: +${r.entries}`).join('\n') ||
|
||||||
|
'None';
|
||||||
|
const levelsText =
|
||||||
|
bonusEntries.levels
|
||||||
|
?.map((l) => `Level ${l.threshold}+: +${l.entries}`)
|
||||||
|
.join('\n') || 'None';
|
||||||
|
const messagesText =
|
||||||
|
bonusEntries.messages
|
||||||
|
?.map((m) => `${m.threshold}+ messages: +${m.entries}`)
|
||||||
|
.join('\n') || 'None';
|
||||||
|
|
||||||
|
return new EmbedBuilder()
|
||||||
|
.setTitle('🎉 Giveaway Creation - Step 4/5')
|
||||||
|
.setDescription('Configure bonus entries for your giveaway.')
|
||||||
|
.setColor(0x3498db)
|
||||||
|
.addFields([
|
||||||
|
{ name: 'Role Bonuses', value: rolesText, inline: true },
|
||||||
|
{ name: 'Level Bonuses', value: levelsText, inline: true },
|
||||||
|
{ name: 'Message Bonuses', value: messagesText, inline: true },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStep4Buttons(): ActionRowBuilder<ButtonBuilder>[] {
|
||||||
|
return [
|
||||||
|
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId('giveaway_bonus_entries')
|
||||||
|
.setLabel('Set Bonus Entries')
|
||||||
|
.setStyle(ButtonStyle.Primary),
|
||||||
|
),
|
||||||
|
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId('giveaway_previous')
|
||||||
|
.setLabel('Previous Step')
|
||||||
|
.setStyle(ButtonStyle.Secondary),
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId('giveaway_next')
|
||||||
|
.setLabel('Next Step')
|
||||||
|
.setStyle(ButtonStyle.Success),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStep5Embed(session: GiveawaySession): EmbedBuilder {
|
||||||
|
return new EmbedBuilder()
|
||||||
|
.setTitle('🎉 Giveaway Creation - Step 5/5')
|
||||||
|
.setDescription('Finalize your giveaway settings.')
|
||||||
|
.setColor(0x3498db)
|
||||||
|
.addFields([
|
||||||
|
{
|
||||||
|
name: 'Role to Ping',
|
||||||
|
value: session.pingRoleId ? `<@&${session.pingRoleId}>` : 'None',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStep5Buttons(): ActionRowBuilder<ButtonBuilder>[] {
|
||||||
|
return [
|
||||||
|
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId('giveaway_set_ping_role')
|
||||||
|
.setLabel('Set Ping Role')
|
||||||
|
.setStyle(ButtonStyle.Primary),
|
||||||
|
),
|
||||||
|
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId('giveaway_previous')
|
||||||
|
.setLabel('Previous Step')
|
||||||
|
.setStyle(ButtonStyle.Secondary),
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId('giveaway_publish')
|
||||||
|
.setLabel('Create Giveaway')
|
||||||
|
.setStyle(ButtonStyle.Success),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
4
src/util/giveaways/constants.ts
Normal file
4
src/util/giveaways/constants.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
export const SESSION_TIMEOUT = 1800;
|
||||||
|
export const SESSION_PREFIX = 'giveaway:session:';
|
||||||
|
export const DEFAULT_WINNER_COUNT = 1;
|
||||||
|
export const DEFAULT_REQUIRE_ALL = true;
|
149
src/util/giveaways/dropdowns.ts
Normal file
149
src/util/giveaways/dropdowns.ts
Normal file
|
@ -0,0 +1,149 @@
|
||||||
|
import {
|
||||||
|
ActionRowBuilder,
|
||||||
|
ButtonInteraction,
|
||||||
|
StringSelectMenuBuilder,
|
||||||
|
} from 'discord.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a select menu for pinging a role.
|
||||||
|
* @param interaction The button interaction that triggered this function.
|
||||||
|
*/
|
||||||
|
export async function showPingRoleSelect(
|
||||||
|
interaction: ButtonInteraction,
|
||||||
|
): Promise<void> {
|
||||||
|
const roles = interaction.guild?.roles.cache
|
||||||
|
.filter((role) => role.id !== interaction.guild?.id)
|
||||||
|
.sort((a, b) => a.position - b.position)
|
||||||
|
.map((role) => ({
|
||||||
|
label: role.name.substring(0, 25),
|
||||||
|
value: role.id,
|
||||||
|
description: `@${role.name}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!roles?.length) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'No roles found in this server.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
||||||
|
new StringSelectMenuBuilder()
|
||||||
|
.setCustomId('giveaway_ping_role_select')
|
||||||
|
.setPlaceholder('Select a role to ping (optional)')
|
||||||
|
.addOptions([...roles.slice(0, 25)]),
|
||||||
|
);
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'Select a role to ping when the giveaway starts:',
|
||||||
|
components: [row],
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a select menu for choosing a duration for the giveaway.
|
||||||
|
* @param interaction The button interaction that triggered this function.
|
||||||
|
*/
|
||||||
|
export async function showDurationSelect(
|
||||||
|
interaction: ButtonInteraction,
|
||||||
|
): Promise<void> {
|
||||||
|
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
||||||
|
new StringSelectMenuBuilder()
|
||||||
|
.setCustomId('giveaway_duration_select')
|
||||||
|
.setPlaceholder('Select duration')
|
||||||
|
.addOptions([
|
||||||
|
{ label: '1 hour', value: '1h', description: 'End giveaway in 1 hour' },
|
||||||
|
{
|
||||||
|
label: '6 hours',
|
||||||
|
value: '6h',
|
||||||
|
description: 'End giveaway in 6 hours',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '12 hours',
|
||||||
|
value: '12h',
|
||||||
|
description: 'End giveaway in 12 hours',
|
||||||
|
},
|
||||||
|
{ label: '1 day', value: '1d', description: 'End giveaway in 1 day' },
|
||||||
|
{ label: '3 days', value: '3d', description: 'End giveaway in 3 days' },
|
||||||
|
{ label: '7 days', value: '7d', description: 'End giveaway in 7 days' },
|
||||||
|
{
|
||||||
|
label: 'Custom',
|
||||||
|
value: 'custom',
|
||||||
|
description: 'Set a custom duration',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'Select the duration for your giveaway:',
|
||||||
|
components: [row],
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a select menu for choosing the number of winners for the giveaway.
|
||||||
|
* @param interaction The button interaction that triggered this function.
|
||||||
|
*/
|
||||||
|
export async function showWinnerSelect(
|
||||||
|
interaction: ButtonInteraction,
|
||||||
|
): Promise<void> {
|
||||||
|
const options = [1, 2, 3, 5, 10].map((num) => ({
|
||||||
|
label: `${num} winner${num > 1 ? 's' : ''}`,
|
||||||
|
value: num.toString(),
|
||||||
|
description: `Select ${num} winner${num > 1 ? 's' : ''} for the giveaway`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
||||||
|
new StringSelectMenuBuilder()
|
||||||
|
.setCustomId('giveaway_winners_select')
|
||||||
|
.setPlaceholder('Select number of winners')
|
||||||
|
.addOptions(options),
|
||||||
|
);
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'How many winners should this giveaway have?',
|
||||||
|
components: [row],
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a select menu for choosing a channel for the giveaway.
|
||||||
|
* @param interaction The button interaction that triggered this function.
|
||||||
|
*/
|
||||||
|
export async function showChannelSelect(
|
||||||
|
interaction: ButtonInteraction,
|
||||||
|
): Promise<void> {
|
||||||
|
const channels = interaction.guild?.channels.cache
|
||||||
|
.filter((channel) => channel.isTextBased())
|
||||||
|
.map((channel) => ({
|
||||||
|
label: channel.name.substring(0, 25),
|
||||||
|
value: channel.id,
|
||||||
|
description: `#${channel.name}`,
|
||||||
|
}))
|
||||||
|
.slice(0, 25);
|
||||||
|
|
||||||
|
if (!channels?.length) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'No suitable text channels found in this server.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
||||||
|
new StringSelectMenuBuilder()
|
||||||
|
.setCustomId('giveaway_channel_select')
|
||||||
|
.setPlaceholder('Select a channel')
|
||||||
|
.addOptions(channels),
|
||||||
|
);
|
||||||
|
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'Select the channel to host the giveaway in:',
|
||||||
|
components: [row],
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
}
|
362
src/util/giveaways/giveawayManager.ts
Normal file
362
src/util/giveaways/giveawayManager.ts
Normal file
|
@ -0,0 +1,362 @@
|
||||||
|
import {
|
||||||
|
ButtonInteraction,
|
||||||
|
Client,
|
||||||
|
EmbedBuilder,
|
||||||
|
TextChannel,
|
||||||
|
} from 'discord.js';
|
||||||
|
|
||||||
|
import { createGiveaway, endGiveaway, getActiveGiveaways } from '@/db/db.js';
|
||||||
|
import { GiveawayEmbedParams } from './types.js';
|
||||||
|
import {
|
||||||
|
createGiveawayButtons,
|
||||||
|
deleteSession,
|
||||||
|
formatWinnerMentions,
|
||||||
|
getSession,
|
||||||
|
toggleRequirementLogic,
|
||||||
|
selectGiveawayWinners,
|
||||||
|
} from './utils.js';
|
||||||
|
import { loadConfig } from '../configLoader.js';
|
||||||
|
import * as builder from './builder.js';
|
||||||
|
import * as dropdowns from './dropdowns.js';
|
||||||
|
import * as handlers from './handlers.js';
|
||||||
|
import * as modals from './modals.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Discord embed for a giveaway based on the provided parameters.
|
||||||
|
* Handles both active and ended giveaway states.
|
||||||
|
*
|
||||||
|
* @param params - The parameters needed to build the giveaway embed.
|
||||||
|
* @returns A configured EmbedBuilder instance for the giveaway.
|
||||||
|
*/
|
||||||
|
export function createGiveawayEmbed(params: GiveawayEmbedParams): EmbedBuilder {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
prize,
|
||||||
|
endTime,
|
||||||
|
winnerCount = 1,
|
||||||
|
hostId,
|
||||||
|
participantCount = 0,
|
||||||
|
winnersIds,
|
||||||
|
isEnded = false,
|
||||||
|
footerText,
|
||||||
|
requiredLevel,
|
||||||
|
requiredRoleId,
|
||||||
|
requiredMessageCount,
|
||||||
|
requireAllCriteria = true,
|
||||||
|
bonusEntries,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(isEnded ? '🎉 Giveaway Ended 🎉' : '🎉 Giveaway 🎉')
|
||||||
|
.setDescription(
|
||||||
|
`**Prize**: ${prize}${id ? `\n**Giveaway ID**: ${id}` : ''}`,
|
||||||
|
)
|
||||||
|
.setColor(isEnded ? 0xff0000 : 0x00ff00);
|
||||||
|
|
||||||
|
if (isEnded) {
|
||||||
|
embed.addFields(
|
||||||
|
{ name: 'Winner(s)', value: formatWinnerMentions(winnersIds) },
|
||||||
|
{ name: 'Hosted by', value: `<@${hostId}>` },
|
||||||
|
);
|
||||||
|
embed.setFooter({ text: footerText || 'Ended at' });
|
||||||
|
embed.setTimestamp();
|
||||||
|
} else {
|
||||||
|
embed.addFields(
|
||||||
|
{ name: 'Winner(s)', value: winnerCount.toString(), inline: true },
|
||||||
|
{ name: 'Entries', value: participantCount.toString(), inline: true },
|
||||||
|
{
|
||||||
|
name: 'Ends at',
|
||||||
|
value: endTime
|
||||||
|
? `<t:${Math.floor(endTime.getTime() / 1000)}:R>`
|
||||||
|
: 'Soon',
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
{ name: 'Hosted by', value: `<@${hostId}>` },
|
||||||
|
);
|
||||||
|
|
||||||
|
const requirements: string[] = [];
|
||||||
|
if (requiredLevel) requirements.push(`• Level ${requiredLevel}+ required`);
|
||||||
|
if (requiredRoleId) {
|
||||||
|
requirements.push(`• <@&${requiredRoleId}> role required`);
|
||||||
|
}
|
||||||
|
if (requiredMessageCount) {
|
||||||
|
requirements.push(`• ${requiredMessageCount}+ messages required`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requirements.length) {
|
||||||
|
embed.addFields({
|
||||||
|
name: `📋 Entry Requirements (${requireAllCriteria ? 'ALL required' : 'ANY one required'})`,
|
||||||
|
value: requirements.join('\n'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const bonusDetails: string[] = [];
|
||||||
|
bonusEntries?.roles?.forEach((r) =>
|
||||||
|
bonusDetails.push(`• <@&${r.id}>: +${r.entries} entries`),
|
||||||
|
);
|
||||||
|
bonusEntries?.levels?.forEach((l) =>
|
||||||
|
bonusDetails.push(`• Level ${l.threshold}+: +${l.entries} entries`),
|
||||||
|
);
|
||||||
|
bonusEntries?.messages?.forEach((m) =>
|
||||||
|
bonusDetails.push(`• ${m.threshold}+ messages: +${m.entries} entries`),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (bonusDetails.length) {
|
||||||
|
embed.addFields({
|
||||||
|
name: '✨ Bonus Entries',
|
||||||
|
value: bonusDetails.join('\n'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
embed.setFooter({ text: 'End time' });
|
||||||
|
if (endTime) embed.setTimestamp(endTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes a giveaway that has ended. Fetches the ended giveaway data,
|
||||||
|
* updates the original message, announces the winners (if any), and handles errors.
|
||||||
|
*
|
||||||
|
* @param client - The Discord Client instance.
|
||||||
|
* @param messageId - The message ID of the giveaway to process.
|
||||||
|
*/
|
||||||
|
export async function processEndedGiveaway(
|
||||||
|
client: Client,
|
||||||
|
messageId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const endedGiveaway = await endGiveaway(messageId);
|
||||||
|
if (!endedGiveaway) {
|
||||||
|
console.warn(
|
||||||
|
`Attempted to process non-existent or already ended giveaway: ${messageId}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
const guild = client.guilds.cache.get(config.guildId);
|
||||||
|
if (!guild) {
|
||||||
|
console.error(`Guild ${config.guildId} not found.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = guild.channels.cache.get(endedGiveaway.channelId);
|
||||||
|
if (!channel?.isTextBased()) {
|
||||||
|
console.warn(
|
||||||
|
`Giveaway channel ${endedGiveaway.channelId} not found or not text-based.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const giveawayMessage = await channel.messages.fetch(messageId);
|
||||||
|
if (!giveawayMessage) {
|
||||||
|
console.warn(
|
||||||
|
`Giveaway message ${messageId} not found in channel ${channel.id}.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await giveawayMessage.edit({
|
||||||
|
embeds: [
|
||||||
|
createGiveawayEmbed({
|
||||||
|
id: endedGiveaway.id,
|
||||||
|
prize: endedGiveaway.prize,
|
||||||
|
hostId: endedGiveaway.hostId,
|
||||||
|
winnersIds: endedGiveaway.winnersIds ?? [],
|
||||||
|
isEnded: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
components: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (endedGiveaway.winnersIds?.length) {
|
||||||
|
const winnerMentions = formatWinnerMentions(endedGiveaway.winnersIds);
|
||||||
|
await channel.send({
|
||||||
|
content: `Congratulations ${winnerMentions}! You won **${endedGiveaway.prize}**!`,
|
||||||
|
allowedMentions: { users: endedGiveaway.winnersIds },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await channel.send(
|
||||||
|
`No one entered the giveaway for **${endedGiveaway.prize}**!`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error updating giveaway message ${messageId}:`, error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error processing ended giveaway ${messageId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules all active giveaways fetched from the database to end at their designated time.
|
||||||
|
* If a giveaway's end time is already past, it processes it immediately.
|
||||||
|
* This function should be called on bot startup.
|
||||||
|
*
|
||||||
|
* @param client - The Discord Client instance.
|
||||||
|
*/
|
||||||
|
export async function scheduleGiveaways(client: Client): Promise<void> {
|
||||||
|
try {
|
||||||
|
const activeGiveaways = await getActiveGiveaways();
|
||||||
|
console.log(
|
||||||
|
`Found ${activeGiveaways.length} active giveaways to schedule.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const giveaway of activeGiveaways) {
|
||||||
|
const endTime = giveaway.endAt.getTime();
|
||||||
|
const now = Date.now();
|
||||||
|
const timeLeft = endTime - now;
|
||||||
|
|
||||||
|
if (timeLeft <= 0) {
|
||||||
|
console.log(
|
||||||
|
`Giveaway ID ${giveaway.id} end time has passed. Processing now.`,
|
||||||
|
);
|
||||||
|
await processEndedGiveaway(client, giveaway.messageId);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`Scheduling giveaway ID ${giveaway.id} to end in ${Math.floor(timeLeft / 1000)} seconds.`,
|
||||||
|
);
|
||||||
|
setTimeout(() => {
|
||||||
|
processEndedGiveaway(client, giveaway.messageId);
|
||||||
|
}, timeLeft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('Finished scheduling active giveaways.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error scheduling giveaways:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publishes a giveaway based on the session data associated with the interacting user.
|
||||||
|
* Sends the giveaway message to the designated channel, saves it to the database,
|
||||||
|
* schedules its end, and cleans up the user's session.
|
||||||
|
*
|
||||||
|
* @param interaction - The button interaction triggering the publish action.
|
||||||
|
*/
|
||||||
|
export async function publishGiveaway(
|
||||||
|
interaction: ButtonInteraction,
|
||||||
|
): Promise<void> {
|
||||||
|
await interaction.deferUpdate();
|
||||||
|
const session = await getSession(interaction.user.id);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
await interaction.followUp({
|
||||||
|
content: 'Your giveaway session has expired. Please start over.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.prize || !session.endTime) {
|
||||||
|
await interaction.followUp({
|
||||||
|
content: 'Missing required information. Please complete all steps.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const channelId = session.channelId || interaction.channelId;
|
||||||
|
const channel = await interaction.guild?.channels.fetch(channelId);
|
||||||
|
if (!channel?.isTextBased()) {
|
||||||
|
await interaction.followUp({
|
||||||
|
content: 'Invalid channel selected.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pingContent = session.pingRoleId ? `<@&${session.pingRoleId}>` : '';
|
||||||
|
|
||||||
|
const initialEmbed = createGiveawayEmbed({
|
||||||
|
prize: session.prize,
|
||||||
|
endTime: session.endTime,
|
||||||
|
winnerCount: session.winnerCount,
|
||||||
|
hostId: interaction.user.id,
|
||||||
|
participantCount: 0,
|
||||||
|
requiredLevel: session.requirements?.level,
|
||||||
|
requiredRoleId: session.requirements?.roleId,
|
||||||
|
requiredMessageCount: session.requirements?.messageCount,
|
||||||
|
requireAllCriteria: session.requirements.requireAll,
|
||||||
|
bonusEntries: session.bonusEntries,
|
||||||
|
});
|
||||||
|
|
||||||
|
const giveawayMessage = await (channel as TextChannel).send({
|
||||||
|
content: pingContent,
|
||||||
|
embeds: [initialEmbed],
|
||||||
|
components: [createGiveawayButtons()],
|
||||||
|
allowedMentions: {
|
||||||
|
roles: session.pingRoleId ? [session.pingRoleId] : [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createdGiveaway = await createGiveaway({
|
||||||
|
channelId: channel.id,
|
||||||
|
messageId: giveawayMessage.id,
|
||||||
|
endAt: session.endTime,
|
||||||
|
prize: session.prize,
|
||||||
|
winnerCount: session.winnerCount,
|
||||||
|
hostId: interaction.user.id,
|
||||||
|
requirements: {
|
||||||
|
level: session.requirements?.level,
|
||||||
|
roleId: session.requirements?.roleId,
|
||||||
|
messageCount: session.requirements?.messageCount,
|
||||||
|
requireAll: session.requirements.requireAll,
|
||||||
|
},
|
||||||
|
bonuses: session.bonusEntries,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedEmbed = createGiveawayEmbed({
|
||||||
|
id: createdGiveaway.id,
|
||||||
|
prize: session.prize,
|
||||||
|
endTime: session.endTime,
|
||||||
|
winnerCount: session.winnerCount,
|
||||||
|
hostId: interaction.user.id,
|
||||||
|
participantCount: 0,
|
||||||
|
requiredLevel: session.requirements?.level,
|
||||||
|
requiredRoleId: session.requirements?.roleId,
|
||||||
|
requiredMessageCount: session.requirements?.messageCount,
|
||||||
|
requireAllCriteria: session.requirements.requireAll,
|
||||||
|
bonusEntries: session.bonusEntries,
|
||||||
|
});
|
||||||
|
|
||||||
|
await giveawayMessage.edit({
|
||||||
|
embeds: [updatedEmbed],
|
||||||
|
components: [createGiveawayButtons()],
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeLeft = session.endTime.getTime() - Date.now();
|
||||||
|
setTimeout(() => {
|
||||||
|
processEndedGiveaway(interaction.client, giveawayMessage.id);
|
||||||
|
}, timeLeft);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `✅ Giveaway created successfully in <#${channel.id}>!\nIt will end <t:${Math.floor(session.endTime.getTime() / 1000)}:R>`,
|
||||||
|
components: [],
|
||||||
|
embeds: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await deleteSession(interaction.user.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error publishing giveaway:', error);
|
||||||
|
await interaction.followUp({
|
||||||
|
content:
|
||||||
|
'An error occurred while creating the giveaway. Please try again.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
builder,
|
||||||
|
dropdowns,
|
||||||
|
handlers,
|
||||||
|
modals,
|
||||||
|
toggleRequirementLogic,
|
||||||
|
formatWinnerMentions,
|
||||||
|
selectGiveawayWinners,
|
||||||
|
};
|
452
src/util/giveaways/handlers.ts
Normal file
452
src/util/giveaways/handlers.ts
Normal file
|
@ -0,0 +1,452 @@
|
||||||
|
import {
|
||||||
|
ButtonInteraction,
|
||||||
|
ModalSubmitInteraction,
|
||||||
|
StringSelectMenuInteraction,
|
||||||
|
} from 'discord.js';
|
||||||
|
|
||||||
|
import { addGiveawayParticipant, getGiveaway, getUserLevel } from '@/db/db.js';
|
||||||
|
import { createGiveawayEmbed } from './giveawayManager.js';
|
||||||
|
import {
|
||||||
|
checkUserRequirements,
|
||||||
|
createGiveawayButtons,
|
||||||
|
getSession,
|
||||||
|
parseRoleBonusEntries,
|
||||||
|
parseThresholdBonusEntries,
|
||||||
|
saveSession,
|
||||||
|
} from './utils.js';
|
||||||
|
import { parseDuration } from '../helpers.js';
|
||||||
|
import { showCustomDurationModal } from './modals.js';
|
||||||
|
import { showBuilderStep } from './builder.js';
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// Button Handlers
|
||||||
|
// ========================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the entry for a giveaway.
|
||||||
|
* @param interaction - The interaction object from the button click
|
||||||
|
*/
|
||||||
|
export async function handleGiveawayEntry(
|
||||||
|
interaction: ButtonInteraction,
|
||||||
|
): Promise<void> {
|
||||||
|
await interaction.deferUpdate();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const messageId = interaction.message.id;
|
||||||
|
const giveaway = await getGiveaway(messageId);
|
||||||
|
|
||||||
|
if (!giveaway || giveaway.status !== 'active') {
|
||||||
|
await interaction.followUp({
|
||||||
|
content: 'This giveaway has ended or does not exist.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [requirementsFailed, requirementsMet] = await checkUserRequirements(
|
||||||
|
interaction,
|
||||||
|
giveaway,
|
||||||
|
);
|
||||||
|
const requireAll = giveaway.requireAllCriteria ?? true;
|
||||||
|
const totalRequirements = [
|
||||||
|
giveaway.requiredLevel,
|
||||||
|
giveaway.requiredRoleId,
|
||||||
|
giveaway.requiredMessageCount,
|
||||||
|
].filter(Boolean).length;
|
||||||
|
|
||||||
|
if (
|
||||||
|
(requireAll && requirementsFailed.length) ||
|
||||||
|
(!requireAll && totalRequirements > 0 && !requirementsMet.length)
|
||||||
|
) {
|
||||||
|
const reqType = requireAll ? 'ALL' : 'ANY ONE';
|
||||||
|
await interaction.followUp({
|
||||||
|
content: `You don't meet the requirements to enter this giveaway (${reqType} required):\n${requirementsFailed.join('\n')}`,
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userData = await getUserLevel(interaction.user.id);
|
||||||
|
const member = await interaction.guild?.members.fetch(interaction.user.id);
|
||||||
|
let totalEntries = 1;
|
||||||
|
|
||||||
|
giveaway.bonusEntries?.roles?.forEach((bonus) => {
|
||||||
|
if (member?.roles.cache.has(bonus.id)) {
|
||||||
|
totalEntries += bonus.entries;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
giveaway.bonusEntries?.levels?.forEach((bonus) => {
|
||||||
|
if (userData.level >= bonus.threshold) {
|
||||||
|
totalEntries += bonus.entries;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
giveaway.bonusEntries?.messages?.forEach((bonus) => {
|
||||||
|
if (userData.messagesSent >= bonus.threshold) {
|
||||||
|
totalEntries += bonus.entries;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const addResult = await addGiveawayParticipant(
|
||||||
|
messageId,
|
||||||
|
interaction.user.id,
|
||||||
|
totalEntries,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (addResult === 'already_entered') {
|
||||||
|
await interaction.followUp({
|
||||||
|
content: 'You have already entered this giveaway!',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addResult === 'inactive') {
|
||||||
|
await interaction.followUp({
|
||||||
|
content: 'This giveaway is no longer active.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addResult === 'error') {
|
||||||
|
await interaction.followUp({
|
||||||
|
content: 'An error occurred while trying to enter the giveaway.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedGiveaway = await getGiveaway(messageId);
|
||||||
|
if (!updatedGiveaway) {
|
||||||
|
console.error(
|
||||||
|
`Failed to fetch giveaway ${messageId} after successful entry.`,
|
||||||
|
);
|
||||||
|
await interaction.followUp({
|
||||||
|
content: `🎉 You have entered the giveaway with ${totalEntries} entries! Good luck! (Failed to update embed)`,
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const embed = createGiveawayEmbed({
|
||||||
|
id: updatedGiveaway.id,
|
||||||
|
prize: updatedGiveaway.prize,
|
||||||
|
endTime: updatedGiveaway.endAt,
|
||||||
|
winnerCount: updatedGiveaway.winnerCount,
|
||||||
|
hostId: updatedGiveaway.hostId,
|
||||||
|
participantCount: updatedGiveaway.participants?.length || 0,
|
||||||
|
requiredLevel: updatedGiveaway.requiredLevel ?? undefined,
|
||||||
|
requiredRoleId: updatedGiveaway.requiredRoleId ?? undefined,
|
||||||
|
requiredMessageCount: updatedGiveaway.requiredMessageCount ?? undefined,
|
||||||
|
requireAllCriteria: updatedGiveaway.requireAllCriteria ?? undefined,
|
||||||
|
bonusEntries: updatedGiveaway.bonusEntries,
|
||||||
|
});
|
||||||
|
|
||||||
|
await interaction.message.edit({
|
||||||
|
embeds: [embed],
|
||||||
|
components: [createGiveawayButtons()],
|
||||||
|
});
|
||||||
|
|
||||||
|
await interaction.followUp({
|
||||||
|
content: `🎉 You have entered the giveaway with **${totalEntries}** entries! Good luck!`,
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling giveaway entry:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// Dropdown Handlers
|
||||||
|
// ========================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the duration selection for the giveaway.
|
||||||
|
* @param interaction - The interaction object from the dropdown selection
|
||||||
|
*/
|
||||||
|
export async function handleDurationSelect(
|
||||||
|
interaction: StringSelectMenuInteraction,
|
||||||
|
): Promise<void> {
|
||||||
|
const duration = interaction.values[0];
|
||||||
|
|
||||||
|
if (duration === 'custom') {
|
||||||
|
showCustomDurationModal(interaction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await getSession(interaction.user.id);
|
||||||
|
if (!session) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'Your giveaway session has expired. Please start over.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationMs = parseDuration(duration);
|
||||||
|
if (durationMs) {
|
||||||
|
session.duration = duration;
|
||||||
|
session.endTime = new Date(Date.now() + durationMs);
|
||||||
|
await saveSession(interaction.user.id, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
await showBuilderStep(interaction, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the winner selection for the giveaway.
|
||||||
|
* @param interaction - The interaction object from the dropdown selection
|
||||||
|
*/
|
||||||
|
export async function handleWinnerSelect(
|
||||||
|
interaction: StringSelectMenuInteraction,
|
||||||
|
): Promise<void> {
|
||||||
|
const winnerCount = parseInt(interaction.values[0]);
|
||||||
|
const session = await getSession(interaction.user.id);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'Your giveaway session has expired. Please start over.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.winnerCount = winnerCount;
|
||||||
|
await saveSession(interaction.user.id, session);
|
||||||
|
|
||||||
|
await showBuilderStep(interaction, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the channel selection for the giveaway.
|
||||||
|
* @param interaction - The interaction object from the dropdown selection
|
||||||
|
*/
|
||||||
|
export async function handleChannelSelect(
|
||||||
|
interaction: StringSelectMenuInteraction,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const channelId = interaction.values[0];
|
||||||
|
const session = await getSession(interaction.user.id);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'Your giveaway session has expired. Please start over.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.channelId = channelId;
|
||||||
|
await saveSession(interaction.user.id, session);
|
||||||
|
|
||||||
|
if (interaction.replied || interaction.deferred) {
|
||||||
|
await showBuilderStep(interaction, session);
|
||||||
|
} else {
|
||||||
|
await interaction.deferUpdate();
|
||||||
|
await showBuilderStep(interaction, session);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in handleChannelSelect:', error);
|
||||||
|
if (!interaction.replied) {
|
||||||
|
await interaction
|
||||||
|
.reply({
|
||||||
|
content: 'An error occurred while processing your selection.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the requirements selection for the giveaway.
|
||||||
|
* @param interaction - The interaction object from the dropdown selection
|
||||||
|
*/
|
||||||
|
export async function handlePingRoleSelect(
|
||||||
|
interaction: StringSelectMenuInteraction,
|
||||||
|
): Promise<void> {
|
||||||
|
const roleId = interaction.values[0];
|
||||||
|
const session = await getSession(interaction.user.id);
|
||||||
|
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
session.pingRoleId = roleId;
|
||||||
|
await saveSession(interaction.user.id, session);
|
||||||
|
await showBuilderStep(interaction, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// Modal Handlers
|
||||||
|
// ========================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the prize input for the giveaway.
|
||||||
|
* @param interaction - The interaction object from the modal submission
|
||||||
|
*/
|
||||||
|
export async function handlePrizeSubmit(
|
||||||
|
interaction: ModalSubmitInteraction,
|
||||||
|
): Promise<void> {
|
||||||
|
const prize = interaction.fields.getTextInputValue('prize_input');
|
||||||
|
const session = await getSession(interaction.user.id);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'Your giveaway session has expired. Please start over.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.prize = prize;
|
||||||
|
await saveSession(interaction.user.id, session);
|
||||||
|
await showBuilderStep(interaction, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the custom duration input for the giveaway.
|
||||||
|
* @param interaction - The interaction object from the modal submission
|
||||||
|
*/
|
||||||
|
export async function handleCustomDurationSubmit(
|
||||||
|
interaction: ModalSubmitInteraction,
|
||||||
|
): Promise<void> {
|
||||||
|
const customDuration = interaction.fields.getTextInputValue('duration_input');
|
||||||
|
const session = await getSession(interaction.user.id);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'Your giveaway session has expired. Please start over.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationMs = parseDuration(customDuration);
|
||||||
|
if (!durationMs || durationMs <= 0) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'Invalid duration format. Please use formats like 1d, 12h, 30m.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.duration = customDuration;
|
||||||
|
session.endTime = new Date(Date.now() + durationMs);
|
||||||
|
await saveSession(interaction.user.id, session);
|
||||||
|
await showBuilderStep(interaction, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the requirements submission for the giveaway.
|
||||||
|
* @param interaction - The interaction object from the modal submission
|
||||||
|
*/
|
||||||
|
export async function handleRequirementsSubmit(
|
||||||
|
interaction: ModalSubmitInteraction,
|
||||||
|
): Promise<void> {
|
||||||
|
const levelStr = interaction.fields.getTextInputValue('level_input');
|
||||||
|
const messageStr = interaction.fields.getTextInputValue('message_input');
|
||||||
|
const roleStr = interaction.fields.getTextInputValue('role_input');
|
||||||
|
const session = await getSession(interaction.user.id);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'Your giveaway session has expired. Please start over.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (levelStr.trim()) {
|
||||||
|
const level = parseInt(levelStr);
|
||||||
|
if (!isNaN(level) && level > 0) {
|
||||||
|
session.requirements.level = level;
|
||||||
|
} else {
|
||||||
|
delete session.requirements.level;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
delete session.requirements.level;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messageStr.trim()) {
|
||||||
|
const messages = parseInt(messageStr);
|
||||||
|
if (!isNaN(messages) && messages > 0) {
|
||||||
|
session.requirements.messageCount = messages;
|
||||||
|
} else {
|
||||||
|
delete session.requirements.messageCount;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
delete session.requirements.messageCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roleStr.trim()) {
|
||||||
|
const roleId = roleStr.replace(/\D/g, '');
|
||||||
|
if (roleId) {
|
||||||
|
session.requirements.roleId = roleId;
|
||||||
|
} else {
|
||||||
|
delete session.requirements.roleId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveSession(interaction.user.id, session);
|
||||||
|
await showBuilderStep(interaction, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the bonus entries submission for the giveaway.
|
||||||
|
* @param interaction - The interaction object from the modal submission
|
||||||
|
*/
|
||||||
|
export async function handleBonusEntriesSubmit(
|
||||||
|
interaction: ModalSubmitInteraction,
|
||||||
|
): Promise<void> {
|
||||||
|
const session = await getSession(interaction.user.id);
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
const rolesStr = interaction.fields.getTextInputValue('roles_input');
|
||||||
|
const levelsStr = interaction.fields.getTextInputValue('levels_input');
|
||||||
|
const messagesStr = interaction.fields.getTextInputValue('messages_input');
|
||||||
|
|
||||||
|
session.bonusEntries = {
|
||||||
|
roles: parseRoleBonusEntries(rolesStr),
|
||||||
|
levels: parseThresholdBonusEntries(levelsStr),
|
||||||
|
messages: parseThresholdBonusEntries(messagesStr),
|
||||||
|
};
|
||||||
|
|
||||||
|
await saveSession(interaction.user.id, session);
|
||||||
|
await showBuilderStep(interaction, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the ping role ID submission for the giveaway.
|
||||||
|
* @param interaction - The interaction object from the modal submission
|
||||||
|
*/
|
||||||
|
export async function handlePingRoleIdSubmit(
|
||||||
|
interaction: ModalSubmitInteraction,
|
||||||
|
): Promise<void> {
|
||||||
|
const roleId = interaction.fields.getTextInputValue('role_input');
|
||||||
|
const session = await getSession(interaction.user.id);
|
||||||
|
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
session.pingRoleId = roleId;
|
||||||
|
await saveSession(interaction.user.id, session);
|
||||||
|
await showBuilderStep(interaction, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the channel ID submission for the giveaway.
|
||||||
|
* @param interaction - The interaction object from the modal submission
|
||||||
|
*/
|
||||||
|
export async function handleChannelIdSubmit(
|
||||||
|
interaction: ModalSubmitInteraction,
|
||||||
|
): Promise<void> {
|
||||||
|
const channelId = interaction.fields.getTextInputValue('channel_input');
|
||||||
|
const session = await getSession(interaction.user.id);
|
||||||
|
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
session.channelId = channelId;
|
||||||
|
await saveSession(interaction.user.id, session);
|
||||||
|
await showBuilderStep(interaction, session);
|
||||||
|
}
|
186
src/util/giveaways/modals.ts
Normal file
186
src/util/giveaways/modals.ts
Normal file
|
@ -0,0 +1,186 @@
|
||||||
|
import {
|
||||||
|
ActionRowBuilder,
|
||||||
|
ButtonInteraction,
|
||||||
|
ModalBuilder,
|
||||||
|
StringSelectMenuInteraction,
|
||||||
|
TextInputBuilder,
|
||||||
|
TextInputStyle,
|
||||||
|
} from 'discord.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a modal to set the prize for a giveaway.
|
||||||
|
* @param interaction The interaction that triggered the modal.
|
||||||
|
*/
|
||||||
|
export async function showPrizeModal(
|
||||||
|
interaction: ButtonInteraction,
|
||||||
|
): Promise<void> {
|
||||||
|
const modal = new ModalBuilder()
|
||||||
|
.setCustomId('giveaway_prize_modal')
|
||||||
|
.setTitle('Set Giveaway Prize');
|
||||||
|
|
||||||
|
const prizeInput = new TextInputBuilder()
|
||||||
|
.setCustomId('prize_input')
|
||||||
|
.setLabel('What are you giving away?')
|
||||||
|
.setPlaceholder('e.g. Discord Nitro, Steam Game, etc.')
|
||||||
|
.setStyle(TextInputStyle.Short)
|
||||||
|
.setRequired(true);
|
||||||
|
|
||||||
|
modal.addComponents(
|
||||||
|
new ActionRowBuilder<TextInputBuilder>().addComponents(prizeInput),
|
||||||
|
);
|
||||||
|
await interaction.showModal(modal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a modal to set custom duration.
|
||||||
|
* @param interaction The interaction that triggered the modal.
|
||||||
|
*/
|
||||||
|
export async function showCustomDurationModal(
|
||||||
|
interaction: StringSelectMenuInteraction,
|
||||||
|
): Promise<void> {
|
||||||
|
const modal = new ModalBuilder()
|
||||||
|
.setCustomId('giveaway_custom_duration')
|
||||||
|
.setTitle('Set Custom Duration');
|
||||||
|
|
||||||
|
const durationInput = new TextInputBuilder()
|
||||||
|
.setCustomId('duration_input')
|
||||||
|
.setLabel('Duration (e.g. 4h30m, 2d12h)')
|
||||||
|
.setPlaceholder('Enter custom duration')
|
||||||
|
.setStyle(TextInputStyle.Short)
|
||||||
|
.setRequired(true);
|
||||||
|
|
||||||
|
modal.addComponents(
|
||||||
|
new ActionRowBuilder<TextInputBuilder>().addComponents(durationInput),
|
||||||
|
);
|
||||||
|
await interaction.showModal(modal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a modal to set entry requirements.
|
||||||
|
* @param interaction The interaction that triggered the modal.
|
||||||
|
*/
|
||||||
|
export async function showRequirementsModal(
|
||||||
|
interaction: ButtonInteraction,
|
||||||
|
): Promise<void> {
|
||||||
|
const modal = new ModalBuilder()
|
||||||
|
.setCustomId('giveaway_requirements_modal')
|
||||||
|
.setTitle('Set Entry Requirements');
|
||||||
|
|
||||||
|
const levelInput = new TextInputBuilder()
|
||||||
|
.setCustomId('level_input')
|
||||||
|
.setLabel('Min level (leave empty for none)')
|
||||||
|
.setPlaceholder('e.g. 10')
|
||||||
|
.setStyle(TextInputStyle.Short)
|
||||||
|
.setRequired(false);
|
||||||
|
|
||||||
|
const messageInput = new TextInputBuilder()
|
||||||
|
.setCustomId('message_input')
|
||||||
|
.setLabel('Min messages (leave empty for none)')
|
||||||
|
.setPlaceholder('e.g. 100')
|
||||||
|
.setStyle(TextInputStyle.Short)
|
||||||
|
.setRequired(false);
|
||||||
|
|
||||||
|
const roleInput = new TextInputBuilder()
|
||||||
|
.setCustomId('role_input')
|
||||||
|
.setLabel('Role ID (leave empty for none)')
|
||||||
|
.setPlaceholder('e.g. 123456789012345678')
|
||||||
|
.setStyle(TextInputStyle.Short)
|
||||||
|
.setRequired(false);
|
||||||
|
|
||||||
|
modal.addComponents(
|
||||||
|
new ActionRowBuilder<TextInputBuilder>().addComponents(levelInput),
|
||||||
|
new ActionRowBuilder<TextInputBuilder>().addComponents(messageInput),
|
||||||
|
new ActionRowBuilder<TextInputBuilder>().addComponents(roleInput),
|
||||||
|
);
|
||||||
|
|
||||||
|
await interaction.showModal(modal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a modal to set bonus entries for the giveaway.
|
||||||
|
* @param interaction The interaction that triggered the modal.
|
||||||
|
*/
|
||||||
|
export async function showBonusEntriesModal(
|
||||||
|
interaction: ButtonInteraction,
|
||||||
|
): Promise<void> {
|
||||||
|
const modal = new ModalBuilder()
|
||||||
|
.setCustomId('giveaway_bonus_entries_modal')
|
||||||
|
.setTitle('Bonus Entries Configuration');
|
||||||
|
|
||||||
|
const rolesInput = new TextInputBuilder()
|
||||||
|
.setCustomId('roles_input')
|
||||||
|
.setLabel('Role bonuses')
|
||||||
|
.setPlaceholder('format: roleId:entries,roleId:entries')
|
||||||
|
.setStyle(TextInputStyle.Short)
|
||||||
|
.setRequired(false);
|
||||||
|
|
||||||
|
const levelsInput = new TextInputBuilder()
|
||||||
|
.setCustomId('levels_input')
|
||||||
|
.setLabel('Level bonuses')
|
||||||
|
.setPlaceholder('format: level:entries,level:entries')
|
||||||
|
.setStyle(TextInputStyle.Short)
|
||||||
|
.setRequired(false);
|
||||||
|
|
||||||
|
const messagesInput = new TextInputBuilder()
|
||||||
|
.setCustomId('messages_input')
|
||||||
|
.setLabel('Message bonuses')
|
||||||
|
.setPlaceholder('format: count:entries,count:entries')
|
||||||
|
.setStyle(TextInputStyle.Short)
|
||||||
|
.setRequired(false);
|
||||||
|
|
||||||
|
modal.addComponents(
|
||||||
|
new ActionRowBuilder<TextInputBuilder>().addComponents(rolesInput),
|
||||||
|
new ActionRowBuilder<TextInputBuilder>().addComponents(levelsInput),
|
||||||
|
new ActionRowBuilder<TextInputBuilder>().addComponents(messagesInput),
|
||||||
|
);
|
||||||
|
|
||||||
|
await interaction.showModal(modal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a modal to select a role to ping.
|
||||||
|
* @param interaction The interaction that triggered the modal.
|
||||||
|
*/
|
||||||
|
export async function showPingRoleSelectModal(
|
||||||
|
interaction: ButtonInteraction,
|
||||||
|
): Promise<void> {
|
||||||
|
const modal = new ModalBuilder()
|
||||||
|
.setCustomId('giveaway_ping_role_id_modal')
|
||||||
|
.setTitle('Enter Role ID');
|
||||||
|
|
||||||
|
const roleInput = new TextInputBuilder()
|
||||||
|
.setCustomId('role_input')
|
||||||
|
.setLabel('Role ID')
|
||||||
|
.setPlaceholder('Enter the role ID to ping')
|
||||||
|
.setStyle(TextInputStyle.Short)
|
||||||
|
.setRequired(false);
|
||||||
|
|
||||||
|
modal.addComponents(
|
||||||
|
new ActionRowBuilder<TextInputBuilder>().addComponents(roleInput),
|
||||||
|
);
|
||||||
|
await interaction.showModal(modal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a modal to select the channel to host the giveaway.
|
||||||
|
* @param interaction The interaction that triggered the modal.
|
||||||
|
*/
|
||||||
|
export async function showChannelSelectModal(
|
||||||
|
interaction: ButtonInteraction,
|
||||||
|
): Promise<void> {
|
||||||
|
const modal = new ModalBuilder()
|
||||||
|
.setCustomId('giveaway_channel_id_modal')
|
||||||
|
.setTitle('Select Channel for Giveaway');
|
||||||
|
|
||||||
|
const channelInput = new TextInputBuilder()
|
||||||
|
.setCustomId('channel_input')
|
||||||
|
.setLabel('Channel ID')
|
||||||
|
.setPlaceholder('Enter the channel ID to host the giveaway')
|
||||||
|
.setStyle(TextInputStyle.Short)
|
||||||
|
.setRequired(false);
|
||||||
|
|
||||||
|
modal.addComponents(
|
||||||
|
new ActionRowBuilder<TextInputBuilder>().addComponents(channelInput),
|
||||||
|
);
|
||||||
|
await interaction.showModal(modal);
|
||||||
|
}
|
39
src/util/giveaways/types.ts
Normal file
39
src/util/giveaways/types.ts
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
export interface BonusEntries {
|
||||||
|
roles?: Array<{ id: string; entries: number }>;
|
||||||
|
levels?: Array<{ threshold: number; entries: number }>;
|
||||||
|
messages?: Array<{ threshold: number; entries: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiveawaySession {
|
||||||
|
step: number;
|
||||||
|
prize?: string;
|
||||||
|
duration?: string;
|
||||||
|
endTime?: Date;
|
||||||
|
winnerCount: number;
|
||||||
|
channelId?: string;
|
||||||
|
requirements: {
|
||||||
|
level?: number;
|
||||||
|
roleId?: string;
|
||||||
|
messageCount?: number;
|
||||||
|
requireAll: boolean;
|
||||||
|
};
|
||||||
|
pingRoleId?: string;
|
||||||
|
bonusEntries?: BonusEntries;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiveawayEmbedParams {
|
||||||
|
id?: number;
|
||||||
|
prize: string;
|
||||||
|
endTime?: Date;
|
||||||
|
winnerCount?: number;
|
||||||
|
hostId: string;
|
||||||
|
participantCount?: number;
|
||||||
|
winnersIds?: string[];
|
||||||
|
isEnded?: boolean;
|
||||||
|
footerText?: string;
|
||||||
|
requiredLevel?: number;
|
||||||
|
requiredRoleId?: string;
|
||||||
|
requiredMessageCount?: number;
|
||||||
|
requireAllCriteria?: boolean;
|
||||||
|
bonusEntries?: BonusEntries;
|
||||||
|
}
|
220
src/util/giveaways/utils.ts
Normal file
220
src/util/giveaways/utils.ts
Normal file
|
@ -0,0 +1,220 @@
|
||||||
|
import {
|
||||||
|
ActionRowBuilder,
|
||||||
|
ButtonBuilder,
|
||||||
|
ButtonInteraction,
|
||||||
|
ButtonStyle,
|
||||||
|
} from 'discord.js';
|
||||||
|
|
||||||
|
import { del, getJson, setJson } from '@/db/redis.js';
|
||||||
|
import { getUserLevel } from '@/db/db.js';
|
||||||
|
import { GiveawaySession } from './types.js';
|
||||||
|
import { SESSION_PREFIX, SESSION_TIMEOUT } from './constants.js';
|
||||||
|
import { showBuilderStep } from './builder.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select winners for the giveaway.
|
||||||
|
* @param participants - Array of participant IDs
|
||||||
|
* @param winnerCount - Number of winners to select
|
||||||
|
* @param forceWinners - Array of IDs to force as winners
|
||||||
|
* @param excludeIds - Array of IDs to exclude from selection
|
||||||
|
* @returns - Array of winner IDs
|
||||||
|
*/
|
||||||
|
export function selectGiveawayWinners(
|
||||||
|
participants: string[],
|
||||||
|
winnerCount: number,
|
||||||
|
forceWinners?: string[],
|
||||||
|
excludeIds?: string[],
|
||||||
|
): string[] {
|
||||||
|
if (forceWinners?.length) return forceWinners;
|
||||||
|
|
||||||
|
const eligibleParticipants = excludeIds
|
||||||
|
? participants.filter((p) => !excludeIds.includes(p))
|
||||||
|
: participants;
|
||||||
|
|
||||||
|
if (!eligibleParticipants.length) return [];
|
||||||
|
|
||||||
|
const uniqueParticipants = [...new Set(eligibleParticipants)];
|
||||||
|
|
||||||
|
const actualWinnerCount = Math.min(winnerCount, uniqueParticipants.length);
|
||||||
|
const shuffled = uniqueParticipants.sort(() => 0.5 - Math.random());
|
||||||
|
return shuffled.slice(0, actualWinnerCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format the winner mentions for the giveaway embed.
|
||||||
|
* @param winnerIds - Array of winner IDs
|
||||||
|
* @returns - Formatted string of winner mentions
|
||||||
|
*/
|
||||||
|
export function formatWinnerMentions(winnerIds?: string[]): string {
|
||||||
|
return winnerIds?.length
|
||||||
|
? winnerIds.map((id) => `<@${id}>`).join(', ')
|
||||||
|
: 'No valid participants';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the giveaway button for users to enter.
|
||||||
|
* @returns - ActionRowBuilder with the giveaway button
|
||||||
|
*/
|
||||||
|
export function createGiveawayButtons(): ActionRowBuilder<ButtonBuilder> {
|
||||||
|
return new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId('enter_giveaway')
|
||||||
|
.setLabel('Enter Giveaway')
|
||||||
|
.setStyle(ButtonStyle.Success)
|
||||||
|
.setEmoji('🎉'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the user meets the giveaway requirements.
|
||||||
|
* @param interaction - Button interaction from Discord
|
||||||
|
* @param giveaway - Giveaway data
|
||||||
|
* @returns - Array of failed and met requirements
|
||||||
|
*/
|
||||||
|
export async function checkUserRequirements(
|
||||||
|
interaction: ButtonInteraction,
|
||||||
|
giveaway: any,
|
||||||
|
): Promise<[string[], string[]]> {
|
||||||
|
const requirementsFailed: string[] = [];
|
||||||
|
const requirementsMet: string[] = [];
|
||||||
|
|
||||||
|
if (giveaway.requiredLevel) {
|
||||||
|
const userData = await getUserLevel(interaction.user.id);
|
||||||
|
if (userData.level < giveaway.requiredLevel) {
|
||||||
|
requirementsFailed.push(
|
||||||
|
`You need to be level ${giveaway.requiredLevel}+ to enter (you're level ${userData.level})`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
requirementsMet.push(`Level requirement met (${userData.level})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (giveaway.requiredRoleId) {
|
||||||
|
const member = await interaction.guild?.members.fetch(interaction.user.id);
|
||||||
|
if (!member?.roles.cache.has(giveaway.requiredRoleId)) {
|
||||||
|
requirementsFailed.push(
|
||||||
|
`You need the <@&${giveaway.requiredRoleId}> role to enter`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
requirementsMet.push('Role requirement met');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (giveaway.requiredMessageCount) {
|
||||||
|
const userData = await getUserLevel(interaction.user.id);
|
||||||
|
if (userData.messagesSent < giveaway.requiredMessageCount) {
|
||||||
|
requirementsFailed.push(
|
||||||
|
`You need to have sent ${giveaway.requiredMessageCount}+ messages to enter (you've sent ${userData.messagesSent})`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
requirementsMet.push(
|
||||||
|
`Message count requirement met (${userData.messagesSent})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [requirementsFailed, requirementsMet];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the user has already entered the giveaway.
|
||||||
|
* @param interaction - Button interaction from Discord
|
||||||
|
* @param giveaway - Giveaway data
|
||||||
|
* @returns - Boolean indicating if the user has entered
|
||||||
|
*/
|
||||||
|
export async function saveSession(
|
||||||
|
userId: string,
|
||||||
|
data: GiveawaySession,
|
||||||
|
): Promise<void> {
|
||||||
|
const sessionToStore = {
|
||||||
|
...data,
|
||||||
|
endTime: data.endTime?.toISOString(),
|
||||||
|
};
|
||||||
|
await setJson(`${SESSION_PREFIX}${userId}`, sessionToStore, SESSION_TIMEOUT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the giveaway session for a user.
|
||||||
|
* @param userId - The ID of the user
|
||||||
|
* @returns - The user's giveaway session or null if not found
|
||||||
|
*/
|
||||||
|
export async function getSession(
|
||||||
|
userId: string,
|
||||||
|
): Promise<GiveawaySession | null> {
|
||||||
|
const session = await getJson<GiveawaySession>(`${SESSION_PREFIX}${userId}`);
|
||||||
|
if (!session) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...session,
|
||||||
|
endTime: session.endTime ? new Date(session.endTime) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the giveaway session for a user.
|
||||||
|
* @param userId - The ID of the user
|
||||||
|
*/
|
||||||
|
export async function deleteSession(userId: string): Promise<void> {
|
||||||
|
await del(`${SESSION_PREFIX}${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the requirement logic for the giveaway session.
|
||||||
|
* @param interaction - Button interaction from Discord
|
||||||
|
*/
|
||||||
|
export async function toggleRequirementLogic(
|
||||||
|
interaction: ButtonInteraction,
|
||||||
|
): Promise<void> {
|
||||||
|
const session = await getSession(interaction.user.id);
|
||||||
|
if (!session) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'Your giveaway session has expired. Please start over.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.requirements.requireAll = !session.requirements.requireAll;
|
||||||
|
await saveSession(interaction.user.id, session);
|
||||||
|
await showBuilderStep(interaction, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the role bonus entries from a string input.
|
||||||
|
* @param input - String input in the format "roleId:entries,roleId:entries"
|
||||||
|
* @returns - Array of objects containing role ID and entries
|
||||||
|
*/
|
||||||
|
export function parseRoleBonusEntries(
|
||||||
|
input: string,
|
||||||
|
): Array<{ id: string; entries: number }> {
|
||||||
|
if (!input.trim()) return [];
|
||||||
|
|
||||||
|
return input
|
||||||
|
.split(',')
|
||||||
|
.map((entry) => entry.trim().split(':'))
|
||||||
|
.filter(([key, value]) => key && value)
|
||||||
|
.map(([key, value]) => ({
|
||||||
|
id: key,
|
||||||
|
entries: Number(value) || 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the level bonus entries from a string input.
|
||||||
|
* @param input - String input in the format "level:entries,level:entries"
|
||||||
|
* @returns - Array of objects containing level and entries
|
||||||
|
*/
|
||||||
|
export function parseThresholdBonusEntries(
|
||||||
|
input: string,
|
||||||
|
): Array<{ threshold: number; entries: number }> {
|
||||||
|
if (!input.trim()) return [];
|
||||||
|
|
||||||
|
return input
|
||||||
|
.split(',')
|
||||||
|
.map((entry) => entry.trim().split(':'))
|
||||||
|
.filter(([key, value]) => key && value)
|
||||||
|
.map(([key, value]) => ({
|
||||||
|
threshold: Number(key) || 0,
|
||||||
|
entries: Number(value) || 0,
|
||||||
|
}));
|
||||||
|
}
|
|
@ -1,15 +1,29 @@
|
||||||
import Canvas from '@napi-rs/canvas';
|
import Canvas from '@napi-rs/canvas';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import { AttachmentBuilder, Client, GuildMember, Guild } from 'discord.js';
|
import {
|
||||||
|
AttachmentBuilder,
|
||||||
|
Client,
|
||||||
|
GuildMember,
|
||||||
|
Guild,
|
||||||
|
Interaction,
|
||||||
|
ButtonStyle,
|
||||||
|
ButtonBuilder,
|
||||||
|
ActionRowBuilder,
|
||||||
|
} from 'discord.js';
|
||||||
import { and, eq } from 'drizzle-orm';
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
|
||||||
import { moderationTable } from '../db/schema.js';
|
import { moderationTable } from '@/db/schema.js';
|
||||||
import { db, updateMember } from '../db/db.js';
|
import { db, handleDbError, updateMember } from '@/db/db.js';
|
||||||
import logAction from './logging/logAction.js';
|
import logAction from './logging/logAction.js';
|
||||||
|
|
||||||
const __dirname = path.resolve();
|
const __dirname = path.resolve();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns a duration string into milliseconds
|
||||||
|
* @param duration - The duration to parse
|
||||||
|
* @returns - The parsed duration in milliseconds
|
||||||
|
*/
|
||||||
export function parseDuration(duration: string): number {
|
export function parseDuration(duration: string): number {
|
||||||
const regex = /^(\d+)(s|m|h|d)$/;
|
const regex = /^(\d+)(s|m|h|d)$/;
|
||||||
const match = duration.match(regex);
|
const match = duration.match(regex);
|
||||||
|
@ -30,17 +44,27 @@ export function parseDuration(duration: string): number {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Member banner types
|
||||||
|
*/
|
||||||
interface generateMemberBannerTypes {
|
interface generateMemberBannerTypes {
|
||||||
member: GuildMember;
|
member: GuildMember;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a welcome banner for a member
|
||||||
|
* @param member - The member to generate a banner for
|
||||||
|
* @param width - The width of the banner
|
||||||
|
* @param height - The height of the banner
|
||||||
|
* @returns - The generated banner
|
||||||
|
*/
|
||||||
export async function generateMemberBanner({
|
export async function generateMemberBanner({
|
||||||
member,
|
member,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
}: generateMemberBannerTypes) {
|
}: generateMemberBannerTypes): Promise<AttachmentBuilder> {
|
||||||
const welcomeBackground = path.join(__dirname, 'assets', 'welcome-bg.png');
|
const welcomeBackground = path.join(__dirname, 'assets', 'welcome-bg.png');
|
||||||
const canvas = Canvas.createCanvas(width, height);
|
const canvas = Canvas.createCanvas(width, height);
|
||||||
const context = canvas.getContext('2d');
|
const context = canvas.getContext('2d');
|
||||||
|
@ -92,12 +116,19 @@ export async function generateMemberBanner({
|
||||||
return attachment;
|
return attachment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules an unban for a user
|
||||||
|
* @param client - The client to use
|
||||||
|
* @param guildId - The guild ID to unban the user from
|
||||||
|
* @param userId - The user ID to unban
|
||||||
|
* @param expiresAt - The date to unban the user at
|
||||||
|
*/
|
||||||
export async function scheduleUnban(
|
export async function scheduleUnban(
|
||||||
client: Client,
|
client: Client,
|
||||||
guildId: string,
|
guildId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
expiresAt: Date,
|
expiresAt: Date,
|
||||||
) {
|
): Promise<void> {
|
||||||
const timeUntilUnban = expiresAt.getTime() - Date.now();
|
const timeUntilUnban = expiresAt.getTime() - Date.now();
|
||||||
if (timeUntilUnban > 0) {
|
if (timeUntilUnban > 0) {
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
|
@ -106,12 +137,19 @@ export async function scheduleUnban(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes an unban for a user
|
||||||
|
* @param client - The client to use
|
||||||
|
* @param guildId - The guild ID to unban the user from
|
||||||
|
* @param userId - The user ID to unban
|
||||||
|
* @param reason - The reason for the unban
|
||||||
|
*/
|
||||||
export async function executeUnban(
|
export async function executeUnban(
|
||||||
client: Client,
|
client: Client,
|
||||||
guildId: string,
|
guildId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
reason?: string,
|
reason?: string,
|
||||||
) {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const guild = await client.guilds.fetch(guildId);
|
const guild = await client.guilds.fetch(guildId);
|
||||||
await guild.members.unban(userId, reason ?? 'Temporary ban expired');
|
await guild.members.unban(userId, reason ?? 'Temporary ban expired');
|
||||||
|
@ -140,16 +178,28 @@ export async function executeUnban(
|
||||||
reason: reason ?? 'Temporary ban expired',
|
reason: reason ?? 'Temporary ban expired',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to unban user ${userId}:`, error);
|
handleDbError(`Failed to unban user ${userId}`, error as Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadActiveBans(client: Client, guild: Guild) {
|
/**
|
||||||
|
* Loads all active bans and schedules unban events
|
||||||
|
* @param client - The client to use
|
||||||
|
* @param guild - The guild to load bans for
|
||||||
|
*/
|
||||||
|
export async function loadActiveBans(
|
||||||
|
client: Client,
|
||||||
|
guild: Guild,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
const activeBans = await db
|
const activeBans = await db
|
||||||
.select()
|
.select()
|
||||||
.from(moderationTable)
|
.from(moderationTable)
|
||||||
.where(
|
.where(
|
||||||
and(eq(moderationTable.action, 'ban'), eq(moderationTable.active, true)),
|
and(
|
||||||
|
eq(moderationTable.action, 'ban'),
|
||||||
|
eq(moderationTable.active, true),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const ban of activeBans) {
|
for (const ban of activeBans) {
|
||||||
|
@ -162,4 +212,174 @@ export async function loadActiveBans(client: Client, guild: Guild) {
|
||||||
await scheduleUnban(client, guild.id, ban.discordId, ban.expiresAt);
|
await scheduleUnban(client, guild.id, ban.discordId, ban.expiresAt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
handleDbError('Failed to load active bans', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Types for the roundRect function
|
||||||
|
*/
|
||||||
|
interface roundRectTypes {
|
||||||
|
ctx: Canvas.SKRSContext2D;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
fill: boolean;
|
||||||
|
radius?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a rounded rectangle
|
||||||
|
* @param ctx - The canvas context to use
|
||||||
|
* @param x - The x position of the rectangle
|
||||||
|
* @param y - The y position of the rectangle
|
||||||
|
* @param width - The width of the rectangle
|
||||||
|
* @param height - The height of the rectangle
|
||||||
|
* @param radius - The radius of the corners
|
||||||
|
* @param fill - Whether to fill the rectangle
|
||||||
|
*/
|
||||||
|
export function roundRect({
|
||||||
|
ctx,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
radius,
|
||||||
|
fill,
|
||||||
|
}: roundRectTypes): void {
|
||||||
|
if (typeof radius === 'undefined') {
|
||||||
|
radius = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + radius, y);
|
||||||
|
ctx.lineTo(x + width - radius, y);
|
||||||
|
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
|
||||||
|
ctx.lineTo(x + width, y + height - radius);
|
||||||
|
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
||||||
|
ctx.lineTo(x + radius, y + height);
|
||||||
|
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
|
||||||
|
ctx.lineTo(x, y + radius);
|
||||||
|
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||||
|
ctx.closePath();
|
||||||
|
|
||||||
|
if (fill) {
|
||||||
|
ctx.fill();
|
||||||
|
} else {
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw wrapped text in multiple lines
|
||||||
|
* @param ctx - The canvas context to use
|
||||||
|
* @param text - The text to draw
|
||||||
|
* @param x - The x position to draw the text
|
||||||
|
* @param y - The y position to draw the text
|
||||||
|
* @param maxWidth - The maximum width of the text
|
||||||
|
* @param lineHeight - The height of each line
|
||||||
|
*/
|
||||||
|
export function drawMultilineText(
|
||||||
|
ctx: Canvas.SKRSContext2D,
|
||||||
|
text: string,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
maxWidth: number,
|
||||||
|
lineHeight: number,
|
||||||
|
) {
|
||||||
|
const words = text.split(' ');
|
||||||
|
let line = '';
|
||||||
|
for (let i = 0; i < words.length; i++) {
|
||||||
|
const testLine = line + words[i] + ' ';
|
||||||
|
if (ctx.measureText(testLine).width > maxWidth && i > 0) {
|
||||||
|
ctx.fillText(line, x, y);
|
||||||
|
line = words[i] + ' ';
|
||||||
|
y += lineHeight;
|
||||||
|
} else {
|
||||||
|
line = testLine;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.fillText(line, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an interaction is valid
|
||||||
|
* @param interaction - The interaction to check
|
||||||
|
* @returns - Whether the interaction is valid
|
||||||
|
*/
|
||||||
|
export async function validateInteraction(
|
||||||
|
interaction: Interaction,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!interaction.inGuild()) return false;
|
||||||
|
if (!interaction.channel) return false;
|
||||||
|
|
||||||
|
if (interaction.isMessageComponent()) {
|
||||||
|
try {
|
||||||
|
await interaction.channel.messages.fetch(interaction.message.id);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely responds to an interaction
|
||||||
|
* @param interaction - The interaction to respond to
|
||||||
|
* @param content - The content to send
|
||||||
|
*/
|
||||||
|
export async function safelyRespond(interaction: Interaction, content: string) {
|
||||||
|
try {
|
||||||
|
if (!interaction.isRepliable()) return;
|
||||||
|
if (interaction.replied || interaction.deferred) {
|
||||||
|
await interaction.followUp({ content, flags: ['Ephemeral'] });
|
||||||
|
} else {
|
||||||
|
await interaction.reply({ content, flags: ['Ephemeral'] });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to respond to interaction:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates pagination buttons for navigating through multiple pages
|
||||||
|
* @param totalPages - The total number of pages
|
||||||
|
* @param currentPage - The current page number
|
||||||
|
* @returns - The action row with pagination buttons
|
||||||
|
*/
|
||||||
|
export function createPaginationButtons(
|
||||||
|
totalPages: number,
|
||||||
|
currentPage: number,
|
||||||
|
): ActionRowBuilder<ButtonBuilder> {
|
||||||
|
return new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId('first')
|
||||||
|
.setLabel('⏮️')
|
||||||
|
.setStyle(ButtonStyle.Primary)
|
||||||
|
.setDisabled(currentPage === 0),
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId('prev')
|
||||||
|
.setLabel('◀️')
|
||||||
|
.setStyle(ButtonStyle.Primary)
|
||||||
|
.setDisabled(currentPage === 0),
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId('pageinfo')
|
||||||
|
.setLabel(`Page ${currentPage + 1}/${totalPages}`)
|
||||||
|
.setStyle(ButtonStyle.Secondary)
|
||||||
|
.setDisabled(true),
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId('next')
|
||||||
|
.setLabel('▶️')
|
||||||
|
.setStyle(ButtonStyle.Primary)
|
||||||
|
.setDisabled(currentPage === totalPages - 1),
|
||||||
|
new ButtonBuilder()
|
||||||
|
.setCustomId('last')
|
||||||
|
.setLabel('⏭️')
|
||||||
|
.setStyle(ButtonStyle.Primary)
|
||||||
|
.setDisabled(currentPage === totalPages - 1),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
309
src/util/levelingSystem.ts
Normal file
309
src/util/levelingSystem.ts
Normal file
|
@ -0,0 +1,309 @@
|
||||||
|
import path from 'path';
|
||||||
|
import Canvas, { GlobalFonts } from '@napi-rs/canvas';
|
||||||
|
import { GuildMember, Message, AttachmentBuilder, Guild } from 'discord.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
addXpToUser,
|
||||||
|
db,
|
||||||
|
getUserLevel,
|
||||||
|
getUserRank,
|
||||||
|
handleDbError,
|
||||||
|
} from '@/db/db.js';
|
||||||
|
import * as schema from '@/db/schema.js';
|
||||||
|
import { loadConfig } from './configLoader.js';
|
||||||
|
import { roundRect } from './helpers.js';
|
||||||
|
import { processMessageAchievements } from './achievementManager.js';
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
const XP_COOLDOWN = config.leveling.xpCooldown * 1000;
|
||||||
|
const MIN_XP = config.leveling.minXpAwarded;
|
||||||
|
const MAX_XP = config.leveling.maxXpAwarded;
|
||||||
|
|
||||||
|
const __dirname = path.resolve();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the amount of XP required to reach the given level
|
||||||
|
* @param level - The level to calculate the XP for
|
||||||
|
* @returns - The amount of XP required to reach the given level
|
||||||
|
*/
|
||||||
|
export const calculateXpForLevel = (level: number): number => {
|
||||||
|
if (level === 0) return 0;
|
||||||
|
return (5 / 6) * level * (2 * level * level + 27 * level + 91);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the level that corresponds to the given amount of XP
|
||||||
|
* @param xp - The amount of XP to calculate the level for
|
||||||
|
* @returns - The level that corresponds to the given amount of XP
|
||||||
|
*/
|
||||||
|
export const calculateLevelFromXp = (xp: number): number => {
|
||||||
|
if (xp < calculateXpForLevel(1)) return 0;
|
||||||
|
|
||||||
|
let low = 1;
|
||||||
|
let high = 200;
|
||||||
|
|
||||||
|
while (low <= high) {
|
||||||
|
const mid = Math.floor((low + high) / 2);
|
||||||
|
const xpForMid = calculateXpForLevel(mid);
|
||||||
|
const xpForNext = calculateXpForLevel(mid + 1);
|
||||||
|
|
||||||
|
if (xp >= xpForMid && xp < xpForNext) {
|
||||||
|
return mid;
|
||||||
|
} else if (xp < xpForMid) {
|
||||||
|
high = mid - 1;
|
||||||
|
} else {
|
||||||
|
low = mid + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return low - 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the amount of XP required to reach the next level
|
||||||
|
* @param level - The level to calculate the XP for
|
||||||
|
* @param currentXp - The current amount of XP
|
||||||
|
* @returns - The amount of XP required to reach the next level
|
||||||
|
*/
|
||||||
|
export const getXpToNextLevel = (level: number, currentXp: number): number => {
|
||||||
|
if (level === 0) return calculateXpForLevel(1) - currentXp;
|
||||||
|
|
||||||
|
const nextLevelXp = calculateXpForLevel(level + 1);
|
||||||
|
return nextLevelXp - currentXp;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recalculates the levels for all users in the database
|
||||||
|
*/
|
||||||
|
export async function recalculateUserLevels() {
|
||||||
|
try {
|
||||||
|
const users = await db.select().from(schema.levelTable);
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
await addXpToUser(user.discordId, 0);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
handleDbError('Failed to recalculate user levels', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes a message for XP
|
||||||
|
* @param message - The message to process for XP
|
||||||
|
* @returns - The result of processing the message
|
||||||
|
*/
|
||||||
|
export async function processMessage(message: Message) {
|
||||||
|
if (message.author.bot || !message.guild) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userId = message.author.id;
|
||||||
|
const userData = await getUserLevel(userId);
|
||||||
|
const oldXp = userData.xp;
|
||||||
|
|
||||||
|
if (userData.lastMessageTimestamp) {
|
||||||
|
const lastMessageTime = new Date(userData.lastMessageTimestamp).getTime();
|
||||||
|
const currentTime = Date.now();
|
||||||
|
|
||||||
|
if (currentTime - lastMessageTime < XP_COOLDOWN) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let xpToAdd = Math.floor(Math.random() * (MAX_XP - MIN_XP + 1)) + MIN_XP;
|
||||||
|
|
||||||
|
if (xpToAdd > 100) {
|
||||||
|
console.error(
|
||||||
|
`Unusually large XP amount generated: ${xpToAdd}. Capping at 100.`,
|
||||||
|
);
|
||||||
|
xpToAdd = 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await addXpToUser(userId, xpToAdd);
|
||||||
|
|
||||||
|
const newUserData = await getUserLevel(userId);
|
||||||
|
if (newUserData.xp > oldXp + 100) {
|
||||||
|
console.error(
|
||||||
|
`Detected abnormal XP increase: ${oldXp} → ${newUserData.xp}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await processMessageAchievements(message);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error processing message for XP:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a rank card for the given member
|
||||||
|
* @param member - The member to generate a rank card for
|
||||||
|
* @param userData - The user's level data
|
||||||
|
* @returns - The rank card as an attachment
|
||||||
|
*/
|
||||||
|
export async function generateRankCard(
|
||||||
|
member: GuildMember,
|
||||||
|
userData: schema.levelTableTypes,
|
||||||
|
) {
|
||||||
|
GlobalFonts.registerFromPath(
|
||||||
|
path.join(__dirname, 'assets', 'fonts', 'Manrope-Bold.ttf'),
|
||||||
|
'Manrope Bold',
|
||||||
|
);
|
||||||
|
GlobalFonts.registerFromPath(
|
||||||
|
path.join(__dirname, 'assets', 'fonts', 'Manrope-Regular.ttf'),
|
||||||
|
'Manrope',
|
||||||
|
);
|
||||||
|
|
||||||
|
const userRank = await getUserRank(userData.discordId);
|
||||||
|
|
||||||
|
const canvas = Canvas.createCanvas(934, 282);
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
|
||||||
|
context.fillStyle = '#23272A';
|
||||||
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
context.fillStyle = '#2C2F33';
|
||||||
|
roundRect({
|
||||||
|
ctx: context,
|
||||||
|
x: 22,
|
||||||
|
y: 22,
|
||||||
|
width: 890,
|
||||||
|
height: 238,
|
||||||
|
radius: 20,
|
||||||
|
fill: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const avatar = await Canvas.loadImage(
|
||||||
|
member.user.displayAvatarURL({ extension: 'png', size: 256 }),
|
||||||
|
);
|
||||||
|
context.save();
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(120, 141, 80, 0, Math.PI * 2);
|
||||||
|
context.closePath();
|
||||||
|
context.clip();
|
||||||
|
context.drawImage(avatar, 40, 61, 160, 160);
|
||||||
|
context.restore();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading avatar image:', error);
|
||||||
|
context.fillStyle = '#5865F2';
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(120, 141, 80, 0, Math.PI * 2);
|
||||||
|
context.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
context.font = '38px "Manrope Bold"';
|
||||||
|
context.fillStyle = '#FFFFFF';
|
||||||
|
context.fillText(member.user.username, 242, 142);
|
||||||
|
|
||||||
|
context.font = '24px "Manrope Bold"';
|
||||||
|
context.fillStyle = '#FFFFFF';
|
||||||
|
context.textAlign = 'end';
|
||||||
|
context.fillText(`LEVEL ${userData.level}`, 890, 82);
|
||||||
|
|
||||||
|
context.font = '24px "Manrope Bold"';
|
||||||
|
context.fillStyle = '#FFFFFF';
|
||||||
|
context.fillText(`RANK #${userRank}`, 890, 122);
|
||||||
|
|
||||||
|
const barWidth = 615;
|
||||||
|
const barHeight = 38;
|
||||||
|
const barX = 242;
|
||||||
|
const barY = 182;
|
||||||
|
|
||||||
|
const currentLevel = userData.level;
|
||||||
|
const currentLevelXp = calculateXpForLevel(currentLevel);
|
||||||
|
const nextLevelXp = calculateXpForLevel(currentLevel + 1);
|
||||||
|
|
||||||
|
const xpNeededForNextLevel = nextLevelXp - currentLevelXp;
|
||||||
|
|
||||||
|
let xpIntoCurrentLevel;
|
||||||
|
if (currentLevel === 0) {
|
||||||
|
xpIntoCurrentLevel = userData.xp;
|
||||||
|
} else {
|
||||||
|
xpIntoCurrentLevel = userData.xp - currentLevelXp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(xpIntoCurrentLevel / xpNeededForNextLevel, 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
context.fillStyle = '#484b4E';
|
||||||
|
roundRect({
|
||||||
|
ctx: context,
|
||||||
|
x: barX,
|
||||||
|
y: barY,
|
||||||
|
width: barWidth,
|
||||||
|
height: barHeight,
|
||||||
|
radius: barHeight / 2,
|
||||||
|
fill: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (progress > 0) {
|
||||||
|
context.fillStyle = '#5865F2';
|
||||||
|
roundRect({
|
||||||
|
ctx: context,
|
||||||
|
x: barX,
|
||||||
|
y: barY,
|
||||||
|
width: barWidth * progress,
|
||||||
|
height: barHeight,
|
||||||
|
radius: barHeight / 2,
|
||||||
|
fill: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
context.textAlign = 'center';
|
||||||
|
context.font = '20px "Manrope"';
|
||||||
|
context.fillStyle = '#A0A0A0';
|
||||||
|
context.fillText(
|
||||||
|
`${xpIntoCurrentLevel.toLocaleString()} / ${xpNeededForNextLevel.toLocaleString()} XP`,
|
||||||
|
barX + barWidth / 2,
|
||||||
|
barY + barHeight / 2 + 7,
|
||||||
|
);
|
||||||
|
|
||||||
|
return new AttachmentBuilder(canvas.toBuffer('image/png'), {
|
||||||
|
name: 'rank-card.png',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assigns level roles to a user based on their new level
|
||||||
|
* @param guild - The guild to assign roles in
|
||||||
|
* @param userId - The userId of the user to assign roles to
|
||||||
|
* @param newLevel - The new level of the user
|
||||||
|
* @returns - The highest role that was assigned
|
||||||
|
*/
|
||||||
|
export async function checkAndAssignLevelRoles(
|
||||||
|
guild: Guild,
|
||||||
|
userId: string,
|
||||||
|
newLevel: number,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
if (!config.roles.levelRoles || config.roles.levelRoles.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = await guild.members.fetch(userId);
|
||||||
|
if (!member) return;
|
||||||
|
|
||||||
|
const rolesToAdd = config.roles.levelRoles
|
||||||
|
.filter((role) => role.level <= newLevel)
|
||||||
|
.map((role) => role.roleId);
|
||||||
|
|
||||||
|
if (rolesToAdd.length === 0) return;
|
||||||
|
|
||||||
|
const newRolesToAdd = rolesToAdd.filter(
|
||||||
|
(roleId) => !member.roles.cache.has(roleId),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newRolesToAdd.length > 0) {
|
||||||
|
await member.roles.add(newRolesToAdd);
|
||||||
|
}
|
||||||
|
|
||||||
|
const highestRole = rolesToAdd[rolesToAdd.length - 1];
|
||||||
|
return highestRole;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error assigning level roles:', error);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,9 @@
|
||||||
import { ChannelType } from 'discord.js';
|
import { ChannelType } from 'discord.js';
|
||||||
import { LogActionType } from './types';
|
import { LogActionType } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Colors for different actions
|
||||||
|
*/
|
||||||
export const ACTION_COLORS: Record<string, number> = {
|
export const ACTION_COLORS: Record<string, number> = {
|
||||||
// Danger actions - Red
|
// Danger actions - Red
|
||||||
ban: 0xff0000,
|
ban: 0xff0000,
|
||||||
|
@ -31,6 +34,9 @@ export const ACTION_COLORS: Record<string, number> = {
|
||||||
default: 0x0099ff,
|
default: 0x0099ff,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emojis for different actions
|
||||||
|
*/
|
||||||
export const ACTION_EMOJIS: Record<LogActionType, string> = {
|
export const ACTION_EMOJIS: Record<LogActionType, string> = {
|
||||||
roleCreate: '⭐',
|
roleCreate: '⭐',
|
||||||
roleDelete: '🗑️',
|
roleDelete: '🗑️',
|
||||||
|
@ -54,6 +60,9 @@ export const ACTION_EMOJIS: Record<LogActionType, string> = {
|
||||||
roleRemove: '➖',
|
roleRemove: '➖',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Types of channels
|
||||||
|
*/
|
||||||
export const CHANNEL_TYPES: Record<number, string> = {
|
export const CHANNEL_TYPES: Record<number, string> = {
|
||||||
[ChannelType.GuildText]: 'Text Channel',
|
[ChannelType.GuildText]: 'Text Channel',
|
||||||
[ChannelType.GuildVoice]: 'Voice Channel',
|
[ChannelType.GuildVoice]: 'Voice Channel',
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import {
|
import {
|
||||||
TextChannel,
|
|
||||||
ButtonStyle,
|
ButtonStyle,
|
||||||
ButtonBuilder,
|
ButtonBuilder,
|
||||||
ActionRowBuilder,
|
ActionRowBuilder,
|
||||||
GuildChannel,
|
GuildChannel,
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
LogActionPayload,
|
LogActionPayload,
|
||||||
ModerationLogAction,
|
ModerationLogAction,
|
||||||
|
@ -22,10 +22,18 @@ import {
|
||||||
getPermissionDifference,
|
getPermissionDifference,
|
||||||
getPermissionNames,
|
getPermissionNames,
|
||||||
} from './utils.js';
|
} from './utils.js';
|
||||||
|
import { loadConfig } from '../configLoader.js';
|
||||||
|
|
||||||
export default async function logAction(payload: LogActionPayload) {
|
/**
|
||||||
const logChannel = payload.guild.channels.cache.get('1007787977432383611');
|
* Logs an action to the log channel
|
||||||
if (!logChannel || !(logChannel instanceof TextChannel)) {
|
* @param payload - The payload to log
|
||||||
|
*/
|
||||||
|
export default async function logAction(
|
||||||
|
payload: LogActionPayload,
|
||||||
|
): Promise<void> {
|
||||||
|
const config = loadConfig();
|
||||||
|
const logChannel = payload.guild.channels.cache.get(config.channels.logs);
|
||||||
|
if (!logChannel?.isTextBased()) {
|
||||||
console.error('Log channel not found or is not a Text Channel.');
|
console.error('Log channel not found or is not a Text Channel.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,9 @@ import {
|
||||||
PermissionsBitField,
|
PermissionsBitField,
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moderation log action types
|
||||||
|
*/
|
||||||
export type ModerationActionType =
|
export type ModerationActionType =
|
||||||
| 'ban'
|
| 'ban'
|
||||||
| 'kick'
|
| 'kick'
|
||||||
|
@ -14,23 +17,38 @@ export type ModerationActionType =
|
||||||
| 'unban'
|
| 'unban'
|
||||||
| 'unmute'
|
| 'unmute'
|
||||||
| 'warn';
|
| 'warn';
|
||||||
|
/**
|
||||||
|
* Message log action types
|
||||||
|
*/
|
||||||
export type MessageActionType = 'messageDelete' | 'messageEdit';
|
export type MessageActionType = 'messageDelete' | 'messageEdit';
|
||||||
|
/**
|
||||||
|
* Member log action types
|
||||||
|
*/
|
||||||
export type MemberActionType =
|
export type MemberActionType =
|
||||||
| 'memberJoin'
|
| 'memberJoin'
|
||||||
| 'memberLeave'
|
| 'memberLeave'
|
||||||
| 'memberUsernameUpdate'
|
| 'memberUsernameUpdate'
|
||||||
| 'memberNicknameUpdate';
|
| 'memberNicknameUpdate';
|
||||||
|
/**
|
||||||
|
* Role log action types
|
||||||
|
*/
|
||||||
export type RoleActionType =
|
export type RoleActionType =
|
||||||
| 'roleAdd'
|
| 'roleAdd'
|
||||||
| 'roleRemove'
|
| 'roleRemove'
|
||||||
| 'roleCreate'
|
| 'roleCreate'
|
||||||
| 'roleDelete'
|
| 'roleDelete'
|
||||||
| 'roleUpdate';
|
| 'roleUpdate';
|
||||||
|
/**
|
||||||
|
* Channel log action types
|
||||||
|
*/
|
||||||
export type ChannelActionType =
|
export type ChannelActionType =
|
||||||
| 'channelCreate'
|
| 'channelCreate'
|
||||||
| 'channelDelete'
|
| 'channelDelete'
|
||||||
| 'channelUpdate';
|
| 'channelUpdate';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All log action types
|
||||||
|
*/
|
||||||
export type LogActionType =
|
export type LogActionType =
|
||||||
| ModerationActionType
|
| ModerationActionType
|
||||||
| MessageActionType
|
| MessageActionType
|
||||||
|
@ -38,6 +56,9 @@ export type LogActionType =
|
||||||
| RoleActionType
|
| RoleActionType
|
||||||
| ChannelActionType;
|
| ChannelActionType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Properties of a role
|
||||||
|
*/
|
||||||
export type RoleProperties = {
|
export type RoleProperties = {
|
||||||
name: string;
|
name: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
@ -45,6 +66,9 @@ export type RoleProperties = {
|
||||||
mentionable: boolean;
|
mentionable: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base log action properties
|
||||||
|
*/
|
||||||
export interface BaseLogAction {
|
export interface BaseLogAction {
|
||||||
guild: Guild;
|
guild: Guild;
|
||||||
action: LogActionType;
|
action: LogActionType;
|
||||||
|
@ -53,6 +77,9 @@ export interface BaseLogAction {
|
||||||
duration?: string;
|
duration?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log action properties for moderation actions
|
||||||
|
*/
|
||||||
export interface ModerationLogAction extends BaseLogAction {
|
export interface ModerationLogAction extends BaseLogAction {
|
||||||
action: ModerationActionType;
|
action: ModerationActionType;
|
||||||
target: GuildMember;
|
target: GuildMember;
|
||||||
|
@ -61,6 +88,9 @@ export interface ModerationLogAction extends BaseLogAction {
|
||||||
duration?: string;
|
duration?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log action properties for message actions
|
||||||
|
*/
|
||||||
export interface MessageLogAction extends BaseLogAction {
|
export interface MessageLogAction extends BaseLogAction {
|
||||||
action: MessageActionType;
|
action: MessageActionType;
|
||||||
message: Message<true>;
|
message: Message<true>;
|
||||||
|
@ -68,11 +98,17 @@ export interface MessageLogAction extends BaseLogAction {
|
||||||
newContent?: string;
|
newContent?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log action properties for member actions
|
||||||
|
*/
|
||||||
export interface MemberLogAction extends BaseLogAction {
|
export interface MemberLogAction extends BaseLogAction {
|
||||||
action: 'memberJoin' | 'memberLeave';
|
action: 'memberJoin' | 'memberLeave';
|
||||||
member: GuildMember;
|
member: GuildMember;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log action properties for member username or nickname updates
|
||||||
|
*/
|
||||||
export interface MemberUpdateAction extends BaseLogAction {
|
export interface MemberUpdateAction extends BaseLogAction {
|
||||||
action: 'memberUsernameUpdate' | 'memberNicknameUpdate';
|
action: 'memberUsernameUpdate' | 'memberNicknameUpdate';
|
||||||
member: GuildMember;
|
member: GuildMember;
|
||||||
|
@ -80,6 +116,9 @@ export interface MemberUpdateAction extends BaseLogAction {
|
||||||
newValue: string;
|
newValue: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log action properties for role actions
|
||||||
|
*/
|
||||||
export interface RoleLogAction extends BaseLogAction {
|
export interface RoleLogAction extends BaseLogAction {
|
||||||
action: 'roleAdd' | 'roleRemove';
|
action: 'roleAdd' | 'roleRemove';
|
||||||
member: GuildMember;
|
member: GuildMember;
|
||||||
|
@ -87,6 +126,9 @@ export interface RoleLogAction extends BaseLogAction {
|
||||||
moderator?: GuildMember;
|
moderator?: GuildMember;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log action properties for role updates
|
||||||
|
*/
|
||||||
export interface RoleUpdateAction extends BaseLogAction {
|
export interface RoleUpdateAction extends BaseLogAction {
|
||||||
action: 'roleUpdate';
|
action: 'roleUpdate';
|
||||||
role: Role;
|
role: Role;
|
||||||
|
@ -97,12 +139,18 @@ export interface RoleUpdateAction extends BaseLogAction {
|
||||||
moderator?: GuildMember;
|
moderator?: GuildMember;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log action properties for role creation or deletion
|
||||||
|
*/
|
||||||
export interface RoleCreateDeleteAction extends BaseLogAction {
|
export interface RoleCreateDeleteAction extends BaseLogAction {
|
||||||
action: 'roleCreate' | 'roleDelete';
|
action: 'roleCreate' | 'roleDelete';
|
||||||
role: Role;
|
role: Role;
|
||||||
moderator?: GuildMember;
|
moderator?: GuildMember;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log action properties for channel actions
|
||||||
|
*/
|
||||||
export interface ChannelLogAction extends BaseLogAction {
|
export interface ChannelLogAction extends BaseLogAction {
|
||||||
action: ChannelActionType;
|
action: ChannelActionType;
|
||||||
channel: GuildChannel;
|
channel: GuildChannel;
|
||||||
|
@ -123,6 +171,9 @@ export interface ChannelLogAction extends BaseLogAction {
|
||||||
moderator?: GuildMember;
|
moderator?: GuildMember;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload for a log action
|
||||||
|
*/
|
||||||
export type LogActionPayload =
|
export type LogActionPayload =
|
||||||
| ModerationLogAction
|
| ModerationLogAction
|
||||||
| MessageLogAction
|
| MessageLogAction
|
||||||
|
|
|
@ -5,9 +5,15 @@ import {
|
||||||
EmbedField,
|
EmbedField,
|
||||||
PermissionsBitField,
|
PermissionsBitField,
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
|
|
||||||
import { LogActionPayload, LogActionType, RoleProperties } from './types.js';
|
import { LogActionPayload, LogActionType, RoleProperties } from './types.js';
|
||||||
import { ACTION_EMOJIS } from './constants.js';
|
import { ACTION_EMOJIS } from './constants.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a permission name to be more readable
|
||||||
|
* @param perm - The permission to format
|
||||||
|
* @returns - The formatted permission name
|
||||||
|
*/
|
||||||
export const formatPermissionName = (perm: string): string => {
|
export const formatPermissionName = (perm: string): string => {
|
||||||
return perm
|
return perm
|
||||||
.split('_')
|
.split('_')
|
||||||
|
@ -15,6 +21,12 @@ export const formatPermissionName = (perm: string): string => {
|
||||||
.join(' ');
|
.join(' ');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a field for a user
|
||||||
|
* @param user - The user to create a field for
|
||||||
|
* @param label - The label for the field
|
||||||
|
* @returns - The created field
|
||||||
|
*/
|
||||||
export const createUserField = (
|
export const createUserField = (
|
||||||
user: User | GuildMember,
|
user: User | GuildMember,
|
||||||
label = 'User',
|
label = 'User',
|
||||||
|
@ -24,6 +36,12 @@ export const createUserField = (
|
||||||
inline: true,
|
inline: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a field for a moderator
|
||||||
|
* @param moderator - The moderator to create a field for
|
||||||
|
* @param label - The label for the field
|
||||||
|
* @returns - The created field
|
||||||
|
*/
|
||||||
export const createModeratorField = (
|
export const createModeratorField = (
|
||||||
moderator?: GuildMember,
|
moderator?: GuildMember,
|
||||||
label = 'Moderator',
|
label = 'Moderator',
|
||||||
|
@ -36,12 +54,23 @@ export const createModeratorField = (
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a field for a channel
|
||||||
|
* @param channel - The channel to create a field for
|
||||||
|
* @returns - The created field
|
||||||
|
*/
|
||||||
export const createChannelField = (channel: GuildChannel): EmbedField => ({
|
export const createChannelField = (channel: GuildChannel): EmbedField => ({
|
||||||
name: 'Channel',
|
name: 'Channel',
|
||||||
value: `<#${channel.id}>`,
|
value: `<#${channel.id}>`,
|
||||||
inline: true,
|
inline: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a field for changed permissions
|
||||||
|
* @param oldPerms - The old permissions
|
||||||
|
* @param newPerms - The new permissions
|
||||||
|
* @returns - The created fields
|
||||||
|
*/
|
||||||
export const createPermissionChangeFields = (
|
export const createPermissionChangeFields = (
|
||||||
oldPerms: Readonly<PermissionsBitField>,
|
oldPerms: Readonly<PermissionsBitField>,
|
||||||
newPerms: Readonly<PermissionsBitField>,
|
newPerms: Readonly<PermissionsBitField>,
|
||||||
|
@ -84,6 +113,11 @@ export const createPermissionChangeFields = (
|
||||||
return fields;
|
return fields;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the names of the permissions in a bitfield
|
||||||
|
* @param permissions - The permissions to get the names of
|
||||||
|
* @returns - The names of the permissions
|
||||||
|
*/
|
||||||
export const getPermissionNames = (
|
export const getPermissionNames = (
|
||||||
permissions: Readonly<PermissionsBitField>,
|
permissions: Readonly<PermissionsBitField>,
|
||||||
): string[] => {
|
): string[] => {
|
||||||
|
@ -98,6 +132,12 @@ export const getPermissionNames = (
|
||||||
return names;
|
return names;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares two bitfields and returns the names of the permissions that are in the first bitfield but not the second
|
||||||
|
* @param a - The first bitfield
|
||||||
|
* @param b - The second bitfield
|
||||||
|
* @returns - The names of the permissions that are in the first bitfield but not the second
|
||||||
|
*/
|
||||||
export const getPermissionDifference = (
|
export const getPermissionDifference = (
|
||||||
a: Readonly<PermissionsBitField>,
|
a: Readonly<PermissionsBitField>,
|
||||||
b: Readonly<PermissionsBitField>,
|
b: Readonly<PermissionsBitField>,
|
||||||
|
@ -114,6 +154,12 @@ export const getPermissionDifference = (
|
||||||
return names;
|
return names;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a field for a role
|
||||||
|
* @param oldRole - The old role
|
||||||
|
* @param newRole - The new role
|
||||||
|
* @returns - The fields for the role changes
|
||||||
|
*/
|
||||||
export const createRoleChangeFields = (
|
export const createRoleChangeFields = (
|
||||||
oldRole: Partial<RoleProperties>,
|
oldRole: Partial<RoleProperties>,
|
||||||
newRole: Partial<RoleProperties>,
|
newRole: Partial<RoleProperties>,
|
||||||
|
@ -153,6 +199,11 @@ export const createRoleChangeFields = (
|
||||||
return fields;
|
return fields;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the ID of the item that was logged
|
||||||
|
* @param payload - The payload to get the log item ID from
|
||||||
|
* @returns - The ID of the log item
|
||||||
|
*/
|
||||||
export const getLogItemId = (payload: LogActionPayload): string => {
|
export const getLogItemId = (payload: LogActionPayload): string => {
|
||||||
switch (payload.action) {
|
switch (payload.action) {
|
||||||
case 'roleCreate':
|
case 'roleCreate':
|
||||||
|
@ -188,6 +239,11 @@ export const getLogItemId = (payload: LogActionPayload): string => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the emoji for an action
|
||||||
|
* @param action - The action to get an emoji for
|
||||||
|
* @returns - The emoji for the action
|
||||||
|
*/
|
||||||
export const getEmojiForAction = (action: LogActionType): string => {
|
export const getEmojiForAction = (action: LogActionType): string => {
|
||||||
return ACTION_EMOJIS[action] || '📝';
|
return ACTION_EMOJIS[action] || '📝';
|
||||||
};
|
};
|
||||||
|
|
151
src/util/notificationHandler.ts
Normal file
151
src/util/notificationHandler.ts
Normal file
|
@ -0,0 +1,151 @@
|
||||||
|
import { Client, Guild, GuildMember } from 'discord.js';
|
||||||
|
import { loadConfig } from './configLoader.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Types of notifications that can be sent
|
||||||
|
*/
|
||||||
|
export enum NotificationType {
|
||||||
|
// Redis notifications
|
||||||
|
REDIS_CONNECTION_LOST = 'REDIS_CONNECTION_LOST',
|
||||||
|
REDIS_CONNECTION_RESTORED = 'REDIS_CONNECTION_RESTORED',
|
||||||
|
|
||||||
|
// Database notifications
|
||||||
|
DATABASE_CONNECTION_LOST = 'DATABASE_CONNECTION_LOST',
|
||||||
|
DATABASE_CONNECTION_RESTORED = 'DATABASE_CONNECTION_RESTORED',
|
||||||
|
|
||||||
|
// Bot notifications
|
||||||
|
BOT_RESTARTING = 'BOT_RESTARTING',
|
||||||
|
BOT_ERROR = 'BOT_ERROR',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps notification types to their messages
|
||||||
|
*/
|
||||||
|
const NOTIFICATION_MESSAGES = {
|
||||||
|
[NotificationType.REDIS_CONNECTION_LOST]:
|
||||||
|
'⚠️ **Redis Connection Lost**\n\nThe bot has lost connection to Redis after multiple retry attempts. Caching functionality is disabled until the connection is restored.',
|
||||||
|
[NotificationType.REDIS_CONNECTION_RESTORED]:
|
||||||
|
'✅ **Redis Connection Restored**\n\nThe bot has successfully reconnected to Redis. All caching functionality has been restored.',
|
||||||
|
|
||||||
|
[NotificationType.DATABASE_CONNECTION_LOST]:
|
||||||
|
'🚨 **Database Connection Lost**\n\nThe bot has lost connection to the database after multiple retry attempts. The bot cannot function properly without database access and will shut down.',
|
||||||
|
[NotificationType.DATABASE_CONNECTION_RESTORED]:
|
||||||
|
'✅ **Database Connection Restored**\n\nThe bot has successfully reconnected to the database.',
|
||||||
|
|
||||||
|
[NotificationType.BOT_RESTARTING]:
|
||||||
|
'🔄 **Bot Restarting**\n\nThe bot is being restarted. Services will be temporarily unavailable.',
|
||||||
|
[NotificationType.BOT_ERROR]:
|
||||||
|
'🚨 **Critical Bot Error**\n\nThe bot has encountered a critical error and may not function correctly.',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Discord-friendly timestamp string
|
||||||
|
* @returns Formatted Discord timestamp string
|
||||||
|
*/
|
||||||
|
function createDiscordTimestamp(): string {
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000);
|
||||||
|
return `<t:${timestamp}:F> (<t:${timestamp}:R>)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all managers with the Manager role
|
||||||
|
* @param guild - The guild to search in
|
||||||
|
* @returns Array of members with the Manager role
|
||||||
|
*/
|
||||||
|
async function getManagers(guild: Guild): Promise<GuildMember[]> {
|
||||||
|
const config = loadConfig();
|
||||||
|
const managerRoleId = config.roles?.staffRoles?.find(
|
||||||
|
(role) => role.name === 'Manager',
|
||||||
|
)?.roleId;
|
||||||
|
|
||||||
|
if (!managerRoleId) {
|
||||||
|
console.warn('Manager role not found in config');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await guild.members.fetch();
|
||||||
|
|
||||||
|
return Array.from(
|
||||||
|
guild.members.cache
|
||||||
|
.filter(
|
||||||
|
(member) => member.roles.cache.has(managerRoleId) && !member.user.bot,
|
||||||
|
)
|
||||||
|
.values(),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching managers:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a notification to users with the Manager role
|
||||||
|
* @param client - Discord client instance
|
||||||
|
* @param type - Type of notification to send
|
||||||
|
* @param customMessage - Optional custom message to append
|
||||||
|
*/
|
||||||
|
export async function notifyManagers(
|
||||||
|
client: Client,
|
||||||
|
type: NotificationType,
|
||||||
|
customMessage?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const config = loadConfig();
|
||||||
|
const guild = client.guilds.cache.get(config.guildId);
|
||||||
|
|
||||||
|
if (!guild) {
|
||||||
|
console.error(`Guild with ID ${config.guildId} not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const managers = await getManagers(guild);
|
||||||
|
|
||||||
|
if (managers.length === 0) {
|
||||||
|
console.warn('No managers found to notify');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseMessage = NOTIFICATION_MESSAGES[type];
|
||||||
|
const timestamp = createDiscordTimestamp();
|
||||||
|
const fullMessage = customMessage
|
||||||
|
? `${baseMessage}\n\n${customMessage}`
|
||||||
|
: baseMessage;
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
for (const manager of managers) {
|
||||||
|
try {
|
||||||
|
await manager.send({
|
||||||
|
content: `${fullMessage}\n\nTimestamp: ${timestamp}`,
|
||||||
|
});
|
||||||
|
successCount++;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Failed to send DM to manager ${manager.user.tag}:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Sent ${type} notification to ${successCount}/${managers.length} managers`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending manager notifications:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a manager-level notification to the console
|
||||||
|
* @param type - Type of notification
|
||||||
|
* @param details - Additional details
|
||||||
|
*/
|
||||||
|
export function logManagerNotification(
|
||||||
|
type: NotificationType,
|
||||||
|
details?: string,
|
||||||
|
): void {
|
||||||
|
const baseMessage = NOTIFICATION_MESSAGES[type].split('\n')[0];
|
||||||
|
console.warn(
|
||||||
|
`MANAGER NOTIFICATION: ${baseMessage}${details ? ` | ${details}` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
|
@ -30,8 +30,10 @@
|
||||||
"module": "esnext" /* Specify what module code is generated. */,
|
"module": "esnext" /* Specify what module code is generated. */,
|
||||||
"rootDir": "src" /* Specify the root folder within your source files. */,
|
"rootDir": "src" /* Specify the root folder within your source files. */,
|
||||||
"moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
|
"moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
|
||||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
"baseUrl": "./" /* Specify the base directory to resolve non-relative module names. */,
|
||||||
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
"paths": {
|
||||||
|
"@/*": ["src/*", "./"]
|
||||||
|
} /* Specify a set of entries that re-map imports to additional lookup locations. */,
|
||||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||||
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
||||||
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
||||||
|
@ -76,7 +78,7 @@
|
||||||
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
|
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
|
||||||
|
|
||||||
/* Interop Constraints */
|
/* Interop Constraints */
|
||||||
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
"isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */,
|
||||||
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
|
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
|
||||||
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||||
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
|
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
|
||||||
|
@ -86,8 +88,8 @@
|
||||||
/* Type Checking */
|
/* Type Checking */
|
||||||
"strict": true /* Enable all strict type-checking options. */,
|
"strict": true /* Enable all strict type-checking options. */,
|
||||||
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
||||||
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
|
"strictNullChecks": true /* When type checking, take into account 'null' and 'undefined'. */,
|
||||||
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
"strictFunctionTypes": true /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */,
|
||||||
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
||||||
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||||
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
||||||
|
@ -106,7 +108,11 @@
|
||||||
|
|
||||||
/* Completeness */
|
/* Completeness */
|
||||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
"skipLibCheck": true /* Skip type checking all .d.ts files. */,
|
||||||
|
"plugins": [
|
||||||
|
{ "transform": "typescript-transform-paths" },
|
||||||
|
{ "transform": "typescript-transform-paths", "afterDeclarations": true }
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"]
|
"include": ["src/**/*"]
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue