mirror of
https://github.com/ahmadk953/poixpixel-discord-bot.git
synced 2025-03-31 08:44:14 +00:00
Merge 7fea10ffd5
into 30880f54c8
This commit is contained in:
commit
67a207fe48
46 changed files with 4635 additions and 278 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:
|
||||
matrix:
|
||||
node-version: [21.x]
|
||||
node-version: [23.x]
|
||||
|
||||
steps:
|
||||
- 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))}`;
|
||||
|
||||
const prettierCommand = 'prettier --write';
|
||||
|
||||
export default {
|
||||
'*.{js,mjs,ts,mts}': [prettierCommand, buildEslintCommand],
|
||||
'*.{json}': [prettierCommand],
|
||||
};
|
17
README.md
17
README.md
|
@ -1,14 +1,27 @@
|
|||
# Poixpixel's Discord Bot
|
||||
|
||||
> [!WARNING]
|
||||
> This Discord bot is not production ready and everything is subject to change
|
||||
> This Discord bot is not production ready.
|
||||
|
||||
> [!TIP]
|
||||
> Want to see the bot in action? [Join our Discord server](https://discord.gg/KRTGjxx7gY).
|
||||
|
||||
## Development Commands
|
||||
|
||||
Install Dependencies: ``yarn install``
|
||||
|
||||
Lint: ``yarn lint``
|
||||
|
||||
Check Formatting: ``yarn format``
|
||||
|
||||
Fix Formatting: ``yarn format:fix``
|
||||
|
||||
Compile: ``yarn compile``
|
||||
|
||||
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.
|
@ -2,15 +2,61 @@
|
|||
"token": "DISCORD_BOT_API_KEY",
|
||||
"clientId": "DISCORD_BOT_ID",
|
||||
"guildId": "DISCORD_SERVER_ID",
|
||||
"dbConnectionString": "POSTGRESQL_CONNECTION_STRING",
|
||||
"redisConnectionString": "REDIS_CONNECTION_STRING",
|
||||
"database": {
|
||||
"dbConnectionString": "POSTGRESQL_CONNECTION_STRING",
|
||||
"maxRetryAttempts": "MAX_RETRY_ATTEMPTS",
|
||||
"retryDelay": "RETRY_DELAY_IN_MS"
|
||||
},
|
||||
"redis": {
|
||||
"redisConnectionString": "REDIS_CONNECTION_STRING",
|
||||
"retryAttempts": "RETRY_ATTEMPTS",
|
||||
"initialRetryDelay": "INITIAL_RETRY_DELAY_IN_MS"
|
||||
},
|
||||
"channels": {
|
||||
"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": {
|
||||
"joinRoles": [
|
||||
"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';
|
||||
|
||||
const config = JSON.parse(fs.readFileSync('./config.json', 'utf8'));
|
||||
const { dbConnectionString } = config;
|
||||
const { database } = config;
|
||||
|
||||
export default defineConfig({
|
||||
out: './drizzle',
|
||||
schema: './src/db/schema.ts',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: dbConnectionString,
|
||||
url: database.dbConnectionString,
|
||||
},
|
||||
});
|
||||
|
|
13
package.json
13
package.json
|
@ -9,10 +9,13 @@
|
|||
"scripts": {
|
||||
"compile": "npx tsc",
|
||||
"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",
|
||||
"format": "prettier --check --ignore-path .prettierignore .",
|
||||
"format:fix": "prettier --write --ignore-path .prettierignore ."
|
||||
"format:fix": "prettier --write --ignore-path .prettierignore .",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@napi-rs/canvas": "^0.1.68",
|
||||
|
@ -22,6 +25,8 @@
|
|||
"pg": "^8.14.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^19.8.0",
|
||||
"@commitlint/config-conventional": "^19.8.0",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@eslint/js": "^9.23.0",
|
||||
"@microsoft/eslint-formatter-sarif": "^3.1.0",
|
||||
|
@ -33,10 +38,12 @@
|
|||
"eslint": "^9.23.0",
|
||||
"eslint-config-prettier": "^10.1.1",
|
||||
"globals": "^16.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^15.5.0",
|
||||
"prettier": "3.5.3",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.2"
|
||||
},
|
||||
"packageManager": "yarn@4.6.0"
|
||||
"packageManager": "yarn@4.7.0"
|
||||
}
|
||||
|
|
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;
|
245
src/commands/fun/fact.ts
Normal file
245
src/commands/fun/fact.ts
Normal file
|
@ -0,0 +1,245 @@
|
|||
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';
|
||||
|
||||
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 pendingFacts = await getPendingFacts();
|
||||
|
||||
if (pendingFacts.length === 0) {
|
||||
await interaction.editReply({
|
||||
content: 'There are no pending facts.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('Pending Facts')
|
||||
.setColor(0x0099ff)
|
||||
.setDescription(
|
||||
pendingFacts
|
||||
.map((fact) => {
|
||||
return `**ID #${fact.id}**\n${fact.content}\nSubmitted by: <@${fact.addedBy}>\nSource: ${fact.source || 'Not provided'}`;
|
||||
})
|
||||
.join('\n\n'),
|
||||
)
|
||||
.setTimestamp();
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [embed],
|
||||
});
|
||||
} 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;
|
171
src/commands/fun/leaderboard.ts
Normal file
171
src/commands/fun/leaderboard.ts
Normal file
|
@ -0,0 +1,171 @@
|
|||
import {
|
||||
SlashCommandBuilder,
|
||||
EmbedBuilder,
|
||||
ButtonBuilder,
|
||||
ActionRowBuilder,
|
||||
ButtonStyle,
|
||||
StringSelectMenuBuilder,
|
||||
APIEmbed,
|
||||
JSONEncodable,
|
||||
} from 'discord.js';
|
||||
|
||||
import { OptionsCommand } from '../../types/CommandTypes.js';
|
||||
import { getLevelLeaderboard } from '../../db/db.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 = () =>
|
||||
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
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 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: 60000,
|
||||
});
|
||||
|
||||
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()) {
|
||||
if (i.customId === 'previous' && currentPage > 0) {
|
||||
currentPage--;
|
||||
} else if (i.customId === 'next' && currentPage < pages.length - 1) {
|
||||
currentPage++;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
49
src/commands/fun/rank.ts
Normal file
49
src/commands/fun/rank.ts
Normal file
|
@ -0,0 +1,49 @@
|
|||
import { GuildMember, 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;
|
|
@ -19,7 +19,7 @@ const command: Command = {
|
|||
execute: async (interaction) => {
|
||||
let members = await getAllMembers();
|
||||
members = members.sort((a, b) =>
|
||||
a.discordUsername.localeCompare(b.discordUsername),
|
||||
(a.discordUsername ?? '').localeCompare(b.discordUsername ?? ''),
|
||||
);
|
||||
|
||||
const ITEMS_PER_PAGE = 15;
|
||||
|
|
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;
|
203
src/commands/util/reconnect.ts
Normal file
203
src/commands/util/reconnect.ts
Normal file
|
@ -0,0 +1,203 @@
|
|||
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;
|
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;
|
969
src/db/db.ts
969
src/db/db.ts
File diff suppressed because it is too large
Load diff
307
src/db/redis.ts
307
src/db/redis.ts
|
@ -1,9 +1,31 @@
|
|||
import Redis from 'ioredis';
|
||||
import { Client } from 'discord.js';
|
||||
|
||||
import { loadConfig } from '../util/configLoader.js';
|
||||
import {
|
||||
logManagerNotification,
|
||||
NotificationType,
|
||||
notifyManagers,
|
||||
} from '../util/notificationHandler.js';
|
||||
|
||||
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 {
|
||||
constructor(
|
||||
message: string,
|
||||
|
@ -14,77 +36,271 @@ class RedisError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
redis.on('error', (error) => {
|
||||
console.error('Redis connection error:', error);
|
||||
throw new RedisError('Failed to connect to Redis instance: ', error);
|
||||
});
|
||||
/**
|
||||
* Redis error handler
|
||||
* @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(
|
||||
key: string,
|
||||
value: string,
|
||||
ttl?: number,
|
||||
): Promise<'OK'> {
|
||||
try {
|
||||
await redis.set(key, value);
|
||||
if (ttl) await redis.expire(key, ttl);
|
||||
} catch (error) {
|
||||
console.error('Redis set error: ', error);
|
||||
throw new RedisError(`Failed to set key: ${key}, `, error as Error);
|
||||
): Promise<'OK' | null> {
|
||||
if (!(await ensureRedisConnection())) {
|
||||
console.warn('Redis unavailable, skipping set operation');
|
||||
return null;
|
||||
}
|
||||
|
||||
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>(
|
||||
key: string,
|
||||
value: T,
|
||||
ttl?: number,
|
||||
): Promise<'OK'> {
|
||||
): Promise<'OK' | null> {
|
||||
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 {
|
||||
return await redis.incr(key);
|
||||
return await redis.incr(`bot:${key}`);
|
||||
} catch (error) {
|
||||
console.error('Redis increment error: ', error);
|
||||
throw new RedisError(`Failed to increment key: ${key}, `, error as Error);
|
||||
return handleRedisError(`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 {
|
||||
return (await redis.exists(key)) === 1;
|
||||
return (await redis.exists(`bot:${key}`)) === 1;
|
||||
} catch (error) {
|
||||
console.error('Redis exists error: ', error);
|
||||
throw new RedisError(
|
||||
`Failed to check if key exists: ${key}, `,
|
||||
return handleRedisError(
|
||||
`Failed to check if key exists: ${key}`,
|
||||
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> {
|
||||
if (!(await ensureRedisConnection())) {
|
||||
console.warn('Redis unavailable, skipping get operation');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await redis.get(key);
|
||||
return await redis.get(`bot:${key}`);
|
||||
} catch (error) {
|
||||
console.error('Redis get error: ', error);
|
||||
throw new RedisError(`Failed to get key: ${key}, `, error as Error);
|
||||
return handleRedisError(`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 {
|
||||
return await redis.mget(keys);
|
||||
return await redis.mget(...keys.map((key) => `bot:${key}`));
|
||||
} catch (error) {
|
||||
console.error('Redis mget error: ', error);
|
||||
throw new RedisError(`Failed to get keys: ${keys}, `, error as Error);
|
||||
return handleRedisError('Failed to get 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> {
|
||||
const value = await get(key);
|
||||
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 {
|
||||
return await redis.del(key);
|
||||
return await redis.del(`bot:${key}`);
|
||||
} catch (error) {
|
||||
console.error('Redis del error: ', error);
|
||||
throw new RedisError(`Failed to delete key: ${key}, `, error as Error);
|
||||
return handleRedisError(`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;
|
||||
}
|
||||
|
|
|
@ -25,6 +25,24 @@ export const memberTable = pgTable('members', {
|
|||
currentlyMuted: boolean('currently_muted').notNull().default(false),
|
||||
});
|
||||
|
||||
export interface levelTableTypes {
|
||||
id?: number;
|
||||
discordId: string;
|
||||
xp: number;
|
||||
level: 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),
|
||||
lastMessageTimestamp: timestamp('last_message_timestamp'),
|
||||
});
|
||||
|
||||
export interface moderationTableTypes {
|
||||
id?: number;
|
||||
discordId: string;
|
||||
|
@ -51,8 +69,20 @@ export const moderationTable = pgTable('moderations', {
|
|||
active: boolean('active').notNull().default(true),
|
||||
});
|
||||
|
||||
export const memberRelations = relations(memberTable, ({ many }) => ({
|
||||
export const memberRelations = relations(memberTable, ({ many, one }) => ({
|
||||
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 }) => ({
|
||||
|
@ -61,3 +91,23 @@ export const moderationRelations = relations(moderationTable, ({ one }) => ({
|
|||
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'),
|
||||
});
|
||||
|
|
|
@ -2,39 +2,99 @@ import { Events, Interaction } from 'discord.js';
|
|||
|
||||
import { ExtendedClient } from '../structures/ExtendedClient.js';
|
||||
import { Event } from '../types/EventTypes.js';
|
||||
import { approveFact, deleteFact } from '../db/db.js';
|
||||
|
||||
export default {
|
||||
name: Events.InteractionCreate,
|
||||
execute: async (interaction: Interaction) => {
|
||||
if (!interaction.isCommand()) return;
|
||||
if (interaction.isCommand()) {
|
||||
const client = interaction.client as ExtendedClient;
|
||||
const command = client.commands.get(interaction.commandName);
|
||||
|
||||
const client = interaction.client as ExtendedClient;
|
||||
const command = client.commands.get(interaction.commandName);
|
||||
if (!command) {
|
||||
console.error(
|
||||
`No command matching ${interaction.commandName} was found.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!command) {
|
||||
console.error(
|
||||
`No command matching ${interaction.commandName} was found.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await command.execute(interaction);
|
||||
} catch (error: any) {
|
||||
console.error(`Error executing ${interaction.commandName}`);
|
||||
console.error(error);
|
||||
|
||||
try {
|
||||
await command.execute(interaction);
|
||||
} catch (error) {
|
||||
console.error(`Error executing ${interaction.commandName}`);
|
||||
console.error(error);
|
||||
const isUnknownInteractionError =
|
||||
error.code === 10062 ||
|
||||
(error.message && error.message.includes('Unknown interaction'));
|
||||
|
||||
if (interaction.replied || interaction.deferred) {
|
||||
await interaction.followUp({
|
||||
content: 'There was an error while executing this command!',
|
||||
flags: ['Ephemeral'],
|
||||
if (!isUnknownInteractionError) {
|
||||
try {
|
||||
if (interaction.replied || interaction.deferred) {
|
||||
await interaction
|
||||
.followUp({
|
||||
content: 'There was an error while executing this command!',
|
||||
flags: ['Ephemeral'],
|
||||
})
|
||||
.catch((e) =>
|
||||
console.error('Failed to send error followup:', e),
|
||||
);
|
||||
} else {
|
||||
await interaction
|
||||
.reply({
|
||||
content: 'There was an error while executing this command!',
|
||||
flags: ['Ephemeral'],
|
||||
})
|
||||
.catch((e) => console.error('Failed to send error reply:', e));
|
||||
}
|
||||
} catch (replyError) {
|
||||
console.error('Failed to respond with error message:', replyError);
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
'Interaction expired before response could be sent (code 10062)',
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (interaction.isButton()) {
|
||||
const { customId } = interaction;
|
||||
|
||||
if (customId.startsWith('approve_fact_')) {
|
||||
if (!interaction.memberPermissions?.has('ModerateMembers')) {
|
||||
await interaction.reply({
|
||||
content: 'You do not have permission to approve facts.',
|
||||
flags: ['Ephemeral'],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const factId = parseInt(customId.replace('approve_fact_', ''), 10);
|
||||
await approveFact(factId);
|
||||
|
||||
await interaction.update({
|
||||
content: `✅ Fact #${factId} has been approved by <@${interaction.user.id}>`,
|
||||
components: [],
|
||||
});
|
||||
} else {
|
||||
await interaction.reply({
|
||||
content: 'There was an error while executing this command!',
|
||||
flags: ['Ephemeral'],
|
||||
} else if (customId.startsWith('reject_fact_')) {
|
||||
if (!interaction.memberPermissions?.has('ModerateMembers')) {
|
||||
await interaction.reply({
|
||||
content: 'You do not have permission to reject facts.',
|
||||
flags: ['Ephemeral'],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const factId = parseInt(customId.replace('reject_fact_', ''), 10);
|
||||
await deleteFact(factId);
|
||||
|
||||
await interaction.update({
|
||||
content: `❌ Fact #${factId} has been rejected by <@${interaction.user.id}>`,
|
||||
components: [],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.warn('Unhandled interaction type:', interaction);
|
||||
return;
|
||||
}
|
||||
},
|
||||
} as Event<typeof Events.InteractionCreate>;
|
||||
|
|
|
@ -1,4 +1,10 @@
|
|||
import { Events, Guild, GuildMember, PartialGuildMember } from 'discord.js';
|
||||
import {
|
||||
Collection,
|
||||
Events,
|
||||
Guild,
|
||||
GuildMember,
|
||||
PartialGuildMember,
|
||||
} from 'discord.js';
|
||||
|
||||
import { updateMember, setMembers } from '../db/db.js';
|
||||
import { generateMemberBanner } from '../util/helpers.js';
|
||||
|
@ -19,12 +25,9 @@ export const memberJoin: Event<typeof Events.GuildMemberAdd> = {
|
|||
}
|
||||
|
||||
try {
|
||||
await setMembers([
|
||||
{
|
||||
discordId: member.user.id,
|
||||
discordUsername: member.user.username,
|
||||
},
|
||||
]);
|
||||
const memberCollection = new Collection<string, GuildMember>();
|
||||
memberCollection.set(member.user.id, member);
|
||||
await setMembers(memberCollection);
|
||||
|
||||
if (!member.user.bot) {
|
||||
const attachment = await generateMemberBanner({
|
||||
|
|
|
@ -1,7 +1,17 @@
|
|||
import { AuditLogEvent, Events, Message, PartialMessage } from 'discord.js';
|
||||
|
||||
import { Event } from '../types/EventTypes.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';
|
||||
|
||||
export const messageDelete: Event<typeof Events.MessageDelete> = {
|
||||
name: Events.MessageDelete,
|
||||
|
@ -62,4 +72,87 @@ 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,
|
||||
);
|
||||
|
||||
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];
|
||||
|
|
|
@ -1,8 +1,15 @@
|
|||
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 { Event } from '../types/EventTypes.js';
|
||||
import { scheduleFactOfTheDay } from '../util/factManager.js';
|
||||
|
||||
import {
|
||||
ensureRedisConnection,
|
||||
setDiscordClient as setRedisDiscordClient,
|
||||
} from '../db/redis.js';
|
||||
import { setDiscordClient as setDbDiscordClient } from '../db/db.js';
|
||||
|
||||
export default {
|
||||
name: Events.ClientReady,
|
||||
|
@ -10,6 +17,12 @@ export default {
|
|||
execute: async (client: Client) => {
|
||||
const config = loadConfig();
|
||||
try {
|
||||
setRedisDiscordClient(client);
|
||||
setDbDiscordClient(client);
|
||||
|
||||
await ensureDbInitialized();
|
||||
await ensureRedisConnection();
|
||||
|
||||
const guild = client.guilds.cache.find(
|
||||
(guilds) => guilds.id === config.guildId,
|
||||
);
|
||||
|
@ -21,8 +34,10 @@ export default {
|
|||
const members = await guild.members.fetch();
|
||||
const nonBotMembers = members.filter((m) => !m.user.bot);
|
||||
await setMembers(nonBotMembers);
|
||||
|
||||
await scheduleFactOfTheDay(client);
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize members in database:', error);
|
||||
console.error('Failed to initialize the bot:', error);
|
||||
}
|
||||
|
||||
console.log(`Ready! Logged in as ${client.user?.tag}`);
|
||||
|
|
|
@ -4,6 +4,9 @@ import { Config } from '../types/ConfigTypes.js';
|
|||
import { deployCommands } from '../util/deployCommand.js';
|
||||
import { registerEvents } from '../util/eventLoader.js';
|
||||
|
||||
/**
|
||||
* Extended client class that extends the default Client class
|
||||
*/
|
||||
export class ExtendedClient extends Client {
|
||||
public commands: Collection<string, Command>;
|
||||
private config: Config;
|
||||
|
|
|
@ -2,14 +2,29 @@ import {
|
|||
CommandInteraction,
|
||||
SlashCommandBuilder,
|
||||
SlashCommandOptionsOnlyBuilder,
|
||||
SlashCommandSubcommandsOnlyBuilder,
|
||||
} from 'discord.js';
|
||||
|
||||
/**
|
||||
* Command interface for normal commands
|
||||
*/
|
||||
export interface Command {
|
||||
data: Omit<SlashCommandBuilder, 'addSubcommand' | 'addSubcommandGroup'>;
|
||||
execute: (interaction: CommandInteraction) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Command interface for options commands
|
||||
*/
|
||||
export interface OptionsCommand {
|
||||
data: SlashCommandOptionsOnlyBuilder;
|
||||
execute: (interaction: CommandInteraction) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Command interface for subcommand commands
|
||||
*/
|
||||
export interface SubcommandCommand {
|
||||
data: SlashCommandSubcommandsOnlyBuilder;
|
||||
execute: (interaction: CommandInteraction) => Promise<void>;
|
||||
}
|
||||
|
|
|
@ -1,14 +1,43 @@
|
|||
/**
|
||||
* Config interface for the bot
|
||||
*/
|
||||
export interface Config {
|
||||
token: string;
|
||||
clientId: string;
|
||||
guildId: string;
|
||||
dbConnectionString: string;
|
||||
redisConnectionString: string;
|
||||
database: {
|
||||
dbConnectionString: string;
|
||||
maxRetryAttempts: number;
|
||||
retryDelay: number;
|
||||
};
|
||||
redis: {
|
||||
redisConnectionString: string;
|
||||
retryAttempts: number;
|
||||
initialRetryDelay: number;
|
||||
};
|
||||
channels: {
|
||||
welcome: string;
|
||||
logs: string;
|
||||
counting: string;
|
||||
factOfTheDay: string;
|
||||
factApproval: string;
|
||||
advancements: string;
|
||||
};
|
||||
roles: {
|
||||
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';
|
||||
|
||||
/**
|
||||
* Event interface for events
|
||||
*/
|
||||
export interface Event<K extends keyof ClientEvents> {
|
||||
name: K;
|
||||
once?: boolean;
|
||||
|
|
|
@ -2,6 +2,10 @@ import { Config } from '../types/ConfigTypes.js';
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Loads the config file from the root directory
|
||||
* @returns - The loaded config object
|
||||
*/
|
||||
export function loadConfig(): Config {
|
||||
try {
|
||||
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,6 @@
|
|||
import { REST, Routes } from 'discord.js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { REST, Routes } from 'discord.js';
|
||||
import { loadConfig } from './configLoader.js';
|
||||
|
||||
const config = loadConfig();
|
||||
|
@ -11,6 +11,11 @@ const commandsPath = path.join(__dirname, 'target', 'commands');
|
|||
|
||||
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 files: string[] = [];
|
||||
const filesInDirectory = fs.readdirSync(directory);
|
||||
|
@ -30,15 +35,21 @@ const getFilesRecursively = (directory: string): string[] => {
|
|||
|
||||
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 () => {
|
||||
try {
|
||||
console.log(
|
||||
`Started refreshing ${commandFiles.length} application (/) commands...`,
|
||||
);
|
||||
|
||||
const existingCommands = (await rest.get(
|
||||
Routes.applicationGuildCommands(clientId, guildId),
|
||||
)) as any[];
|
||||
console.log('Undeploying all existing commands...');
|
||||
await rest.put(Routes.applicationGuildCommands(clientId, guildId), {
|
||||
body: [],
|
||||
});
|
||||
console.log('Successfully undeployed all commands');
|
||||
|
||||
const commands = commandFiles.map(async (file) => {
|
||||
const commandModule = await import(`file://${file}`);
|
||||
|
@ -64,18 +75,6 @@ export const deployCommands = async () => {
|
|||
|
||||
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(
|
||||
Routes.applicationGuildCommands(clientId, guildId),
|
||||
{ body: apiCommands },
|
||||
|
|
|
@ -7,6 +7,10 @@ import { dirname } from 'path';
|
|||
const __filename = fileURLToPath(import.meta.url);
|
||||
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> {
|
||||
try {
|
||||
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);
|
||||
}
|
||||
}
|
|
@ -5,11 +5,16 @@ import { AttachmentBuilder, Client, GuildMember, Guild } from 'discord.js';
|
|||
import { and, eq } from 'drizzle-orm';
|
||||
|
||||
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';
|
||||
|
||||
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 {
|
||||
const regex = /^(\d+)(s|m|h|d)$/;
|
||||
const match = duration.match(regex);
|
||||
|
@ -30,17 +35,27 @@ export function parseDuration(duration: string): number {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Member banner types
|
||||
*/
|
||||
interface generateMemberBannerTypes {
|
||||
member: GuildMember;
|
||||
width: 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({
|
||||
member,
|
||||
width,
|
||||
height,
|
||||
}: generateMemberBannerTypes) {
|
||||
}: generateMemberBannerTypes): Promise<AttachmentBuilder> {
|
||||
const welcomeBackground = path.join(__dirname, 'assets', 'welcome-bg.png');
|
||||
const canvas = Canvas.createCanvas(width, height);
|
||||
const context = canvas.getContext('2d');
|
||||
|
@ -92,12 +107,19 @@ export async function generateMemberBanner({
|
|||
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(
|
||||
client: Client,
|
||||
guildId: string,
|
||||
userId: string,
|
||||
expiresAt: Date,
|
||||
) {
|
||||
): Promise<void> {
|
||||
const timeUntilUnban = expiresAt.getTime() - Date.now();
|
||||
if (timeUntilUnban > 0) {
|
||||
setTimeout(async () => {
|
||||
|
@ -106,12 +128,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(
|
||||
client: Client,
|
||||
guildId: string,
|
||||
userId: string,
|
||||
reason?: string,
|
||||
) {
|
||||
): Promise<void> {
|
||||
try {
|
||||
const guild = await client.guilds.fetch(guildId);
|
||||
await guild.members.unban(userId, reason ?? 'Temporary ban expired');
|
||||
|
@ -140,26 +169,96 @@ export async function executeUnban(
|
|||
reason: reason ?? 'Temporary ban expired',
|
||||
});
|
||||
} 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) {
|
||||
const activeBans = await db
|
||||
.select()
|
||||
.from(moderationTable)
|
||||
.where(
|
||||
and(eq(moderationTable.action, 'ban'), eq(moderationTable.active, true)),
|
||||
);
|
||||
/**
|
||||
* 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
|
||||
.select()
|
||||
.from(moderationTable)
|
||||
.where(
|
||||
and(
|
||||
eq(moderationTable.action, 'ban'),
|
||||
eq(moderationTable.active, true),
|
||||
),
|
||||
);
|
||||
|
||||
for (const ban of activeBans) {
|
||||
if (!ban.expiresAt) continue;
|
||||
for (const ban of activeBans) {
|
||||
if (!ban.expiresAt) continue;
|
||||
|
||||
const timeUntilUnban = ban.expiresAt.getTime() - Date.now();
|
||||
if (timeUntilUnban <= 0) {
|
||||
await executeUnban(client, guild.id, ban.discordId);
|
||||
} else {
|
||||
await scheduleUnban(client, guild.id, ban.discordId, ban.expiresAt);
|
||||
const timeUntilUnban = ban.expiresAt.getTime() - Date.now();
|
||||
if (timeUntilUnban <= 0) {
|
||||
await executeUnban(client, guild.id, ban.discordId);
|
||||
} else {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
281
src/util/levelingSystem.ts
Normal file
281
src/util/levelingSystem.ts
Normal file
|
@ -0,0 +1,281 @@
|
|||
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';
|
||||
|
||||
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 level = 0;
|
||||
while (calculateXpForLevel(level + 1) <= xp) {
|
||||
level++;
|
||||
}
|
||||
|
||||
return level;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
if (userData.lastMessageTimestamp) {
|
||||
const lastMessageTime = new Date(userData.lastMessageTimestamp).getTime();
|
||||
const currentTime = Date.now();
|
||||
|
||||
if (currentTime - lastMessageTime < XP_COOLDOWN) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const xpToAdd = Math.floor(Math.random() * (MAX_XP - MIN_XP + 1)) + MIN_XP;
|
||||
const result = await addXpToUser(userId, xpToAdd);
|
||||
|
||||
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 existingLevelRoles = config.roles.levelRoles.map((r) => r.roleId);
|
||||
const rolesToRemove = member.roles.cache.filter((role) =>
|
||||
existingLevelRoles.includes(role.id),
|
||||
);
|
||||
if (rolesToRemove.size > 0) {
|
||||
await member.roles.remove(rolesToRemove);
|
||||
}
|
||||
|
||||
const highestRole = rolesToAdd[rolesToAdd.length - 1];
|
||||
await member.roles.add(highestRole);
|
||||
|
||||
return highestRole;
|
||||
} catch (error) {
|
||||
console.error('Error assigning level roles:', error);
|
||||
}
|
||||
}
|
|
@ -1,6 +1,9 @@
|
|||
import { ChannelType } from 'discord.js';
|
||||
import { LogActionType } from './types';
|
||||
|
||||
/**
|
||||
* Colors for different actions
|
||||
*/
|
||||
export const ACTION_COLORS: Record<string, number> = {
|
||||
// Danger actions - Red
|
||||
ban: 0xff0000,
|
||||
|
@ -31,6 +34,9 @@ export const ACTION_COLORS: Record<string, number> = {
|
|||
default: 0x0099ff,
|
||||
};
|
||||
|
||||
/**
|
||||
* Emojis for different actions
|
||||
*/
|
||||
export const ACTION_EMOJIS: Record<LogActionType, string> = {
|
||||
roleCreate: '⭐',
|
||||
roleDelete: '🗑️',
|
||||
|
@ -54,6 +60,9 @@ export const ACTION_EMOJIS: Record<LogActionType, string> = {
|
|||
roleRemove: '➖',
|
||||
};
|
||||
|
||||
/**
|
||||
* Types of channels
|
||||
*/
|
||||
export const CHANNEL_TYPES: Record<number, string> = {
|
||||
[ChannelType.GuildText]: 'Text Channel',
|
||||
[ChannelType.GuildVoice]: 'Voice Channel',
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import {
|
||||
TextChannel,
|
||||
ButtonStyle,
|
||||
ButtonBuilder,
|
||||
ActionRowBuilder,
|
||||
GuildChannel,
|
||||
} from 'discord.js';
|
||||
|
||||
import {
|
||||
LogActionPayload,
|
||||
ModerationLogAction,
|
||||
|
@ -22,10 +22,18 @@ import {
|
|||
getPermissionDifference,
|
||||
getPermissionNames,
|
||||
} from './utils.js';
|
||||
import { loadConfig } from '../configLoader.js';
|
||||
|
||||
export default async function logAction(payload: LogActionPayload) {
|
||||
const logChannel = payload.guild.channels.cache.get('1007787977432383611');
|
||||
if (!logChannel || !(logChannel instanceof TextChannel)) {
|
||||
/**
|
||||
* Logs an action to the log channel
|
||||
* @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.');
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -7,6 +7,9 @@ import {
|
|||
PermissionsBitField,
|
||||
} from 'discord.js';
|
||||
|
||||
/**
|
||||
* Moderation log action types
|
||||
*/
|
||||
export type ModerationActionType =
|
||||
| 'ban'
|
||||
| 'kick'
|
||||
|
@ -14,23 +17,38 @@ export type ModerationActionType =
|
|||
| 'unban'
|
||||
| 'unmute'
|
||||
| 'warn';
|
||||
/**
|
||||
* Message log action types
|
||||
*/
|
||||
export type MessageActionType = 'messageDelete' | 'messageEdit';
|
||||
/**
|
||||
* Member log action types
|
||||
*/
|
||||
export type MemberActionType =
|
||||
| 'memberJoin'
|
||||
| 'memberLeave'
|
||||
| 'memberUsernameUpdate'
|
||||
| 'memberNicknameUpdate';
|
||||
/**
|
||||
* Role log action types
|
||||
*/
|
||||
export type RoleActionType =
|
||||
| 'roleAdd'
|
||||
| 'roleRemove'
|
||||
| 'roleCreate'
|
||||
| 'roleDelete'
|
||||
| 'roleUpdate';
|
||||
/**
|
||||
* Channel log action types
|
||||
*/
|
||||
export type ChannelActionType =
|
||||
| 'channelCreate'
|
||||
| 'channelDelete'
|
||||
| 'channelUpdate';
|
||||
|
||||
/**
|
||||
* All log action types
|
||||
*/
|
||||
export type LogActionType =
|
||||
| ModerationActionType
|
||||
| MessageActionType
|
||||
|
@ -38,6 +56,9 @@ export type LogActionType =
|
|||
| RoleActionType
|
||||
| ChannelActionType;
|
||||
|
||||
/**
|
||||
* Properties of a role
|
||||
*/
|
||||
export type RoleProperties = {
|
||||
name: string;
|
||||
color: string;
|
||||
|
@ -45,6 +66,9 @@ export type RoleProperties = {
|
|||
mentionable: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Base log action properties
|
||||
*/
|
||||
export interface BaseLogAction {
|
||||
guild: Guild;
|
||||
action: LogActionType;
|
||||
|
@ -53,6 +77,9 @@ export interface BaseLogAction {
|
|||
duration?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log action properties for moderation actions
|
||||
*/
|
||||
export interface ModerationLogAction extends BaseLogAction {
|
||||
action: ModerationActionType;
|
||||
target: GuildMember;
|
||||
|
@ -61,6 +88,9 @@ export interface ModerationLogAction extends BaseLogAction {
|
|||
duration?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log action properties for message actions
|
||||
*/
|
||||
export interface MessageLogAction extends BaseLogAction {
|
||||
action: MessageActionType;
|
||||
message: Message<true>;
|
||||
|
@ -68,11 +98,17 @@ export interface MessageLogAction extends BaseLogAction {
|
|||
newContent?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log action properties for member actions
|
||||
*/
|
||||
export interface MemberLogAction extends BaseLogAction {
|
||||
action: 'memberJoin' | 'memberLeave';
|
||||
member: GuildMember;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log action properties for member username or nickname updates
|
||||
*/
|
||||
export interface MemberUpdateAction extends BaseLogAction {
|
||||
action: 'memberUsernameUpdate' | 'memberNicknameUpdate';
|
||||
member: GuildMember;
|
||||
|
@ -80,6 +116,9 @@ export interface MemberUpdateAction extends BaseLogAction {
|
|||
newValue: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log action properties for role actions
|
||||
*/
|
||||
export interface RoleLogAction extends BaseLogAction {
|
||||
action: 'roleAdd' | 'roleRemove';
|
||||
member: GuildMember;
|
||||
|
@ -87,6 +126,9 @@ export interface RoleLogAction extends BaseLogAction {
|
|||
moderator?: GuildMember;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log action properties for role updates
|
||||
*/
|
||||
export interface RoleUpdateAction extends BaseLogAction {
|
||||
action: 'roleUpdate';
|
||||
role: Role;
|
||||
|
@ -97,12 +139,18 @@ export interface RoleUpdateAction extends BaseLogAction {
|
|||
moderator?: GuildMember;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log action properties for role creation or deletion
|
||||
*/
|
||||
export interface RoleCreateDeleteAction extends BaseLogAction {
|
||||
action: 'roleCreate' | 'roleDelete';
|
||||
role: Role;
|
||||
moderator?: GuildMember;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log action properties for channel actions
|
||||
*/
|
||||
export interface ChannelLogAction extends BaseLogAction {
|
||||
action: ChannelActionType;
|
||||
channel: GuildChannel;
|
||||
|
@ -123,6 +171,9 @@ export interface ChannelLogAction extends BaseLogAction {
|
|||
moderator?: GuildMember;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for a log action
|
||||
*/
|
||||
export type LogActionPayload =
|
||||
| ModerationLogAction
|
||||
| MessageLogAction
|
||||
|
|
|
@ -5,9 +5,15 @@ import {
|
|||
EmbedField,
|
||||
PermissionsBitField,
|
||||
} from 'discord.js';
|
||||
|
||||
import { LogActionPayload, LogActionType, RoleProperties } from './types.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 => {
|
||||
return perm
|
||||
.split('_')
|
||||
|
@ -15,6 +21,12 @@ export const formatPermissionName = (perm: string): string => {
|
|||
.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 = (
|
||||
user: User | GuildMember,
|
||||
label = 'User',
|
||||
|
@ -24,6 +36,12 @@ export const createUserField = (
|
|||
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 = (
|
||||
moderator?: GuildMember,
|
||||
label = 'Moderator',
|
||||
|
@ -36,12 +54,23 @@ export const createModeratorField = (
|
|||
}
|
||||
: 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 => ({
|
||||
name: 'Channel',
|
||||
value: `<#${channel.id}>`,
|
||||
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 = (
|
||||
oldPerms: Readonly<PermissionsBitField>,
|
||||
newPerms: Readonly<PermissionsBitField>,
|
||||
|
@ -84,6 +113,11 @@ export const createPermissionChangeFields = (
|
|||
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 = (
|
||||
permissions: Readonly<PermissionsBitField>,
|
||||
): string[] => {
|
||||
|
@ -98,6 +132,12 @@ export const getPermissionNames = (
|
|||
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 = (
|
||||
a: Readonly<PermissionsBitField>,
|
||||
b: Readonly<PermissionsBitField>,
|
||||
|
@ -114,6 +154,12 @@ export const getPermissionDifference = (
|
|||
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 = (
|
||||
oldRole: Partial<RoleProperties>,
|
||||
newRole: Partial<RoleProperties>,
|
||||
|
@ -153,6 +199,11 @@ export const createRoleChangeFields = (
|
|||
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 => {
|
||||
switch (payload.action) {
|
||||
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 => {
|
||||
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}` : ''}`,
|
||||
);
|
||||
}
|
Loading…
Add table
Reference in a new issue