Compare commits
129 commits
38e59ae6e7
...
d9f670fcf4
Author | SHA1 | Date | |
---|---|---|---|
|
d9f670fcf4 | ||
|
6d15085ed3 | ||
|
c22cedf2f0 | ||
|
46d222d8ec | ||
|
db616967b5 | ||
|
2a0c6110f4 | ||
|
11fa90d552 | ||
|
47942ecb93 | ||
|
72977ab057 | ||
|
fab4b7b2f7 | ||
|
22eff7f672 | ||
|
60bdbe2db5 | ||
|
40bc8a3720 | ||
|
82538289ac | ||
|
2b5ab48923 | ||
|
a26ad631bb | ||
|
ea978ffacc | ||
|
469f871b72 | ||
|
18b87d2910 | ||
|
d082a061c8 | ||
|
9b55669f25 | ||
|
2e9970aa62 | ||
|
e906d86e5d | ||
|
6f97cae885 | ||
|
48e84d71d2 | ||
|
e6cd3b135f | ||
|
fb9426f7ad | ||
|
3a0352ab10 | ||
|
1b563a8c37 | ||
|
5238063419 | ||
|
0c03dc2462 | ||
|
62f59819c4 | ||
|
7995f59ba1 | ||
|
b812701a21 | ||
|
bfaaed9c96 | ||
20bb1ede8e | |||
|
daa231ea40 | ||
|
6a915fe2d7 | ||
|
4def8df3fe | ||
|
7fd9497fb8 | ||
|
a5b33da0fa | ||
|
b961218206 | ||
|
ec28339f61 | ||
|
311cabec92 | ||
|
7887052f22 | ||
|
d050976484 | ||
|
0610bd41c4 | ||
|
654bdce717 | ||
|
6517b51f22 | ||
|
022ab6d272 | ||
|
0a4a3c0af4 | ||
|
a449129a61 | ||
|
70dff9d696 | ||
|
e9abfa812e | ||
|
986a6a67d0 | ||
|
890ee028cc | ||
|
15cb83be7d | ||
|
77c4b75440 | ||
|
7c2a99daf5 | ||
|
83bbf7b098 | ||
|
b615d25964 | ||
|
20af09b279 | ||
|
49d274f2be | ||
|
14667ad69f | ||
|
072c34d778 | ||
|
be8df5f6a2 | ||
|
2f5c3499e7 | ||
|
830838a6a1 | ||
|
8df866037a | ||
|
08cc92cff3 | ||
|
ca7be976f6 | ||
|
e24d063b95 | ||
|
c286b7e1ca | ||
|
7704ff1ad6 | ||
|
6b3de082d1 | ||
|
2d6d45cad1 | ||
|
60aeb7033c | ||
|
7c4b0fdb5d | ||
|
d90f67a164 | ||
|
5c748fbb79 | ||
|
a1f9c39b9f | ||
|
e81750509b | ||
|
82ece53d97 | ||
|
ebae678eee | ||
|
f1713a95ce | ||
6bf290d447 | |||
a859e0ea4f | |||
|
2a324b501b | ||
|
9aabe2885b | ||
|
d9d5f087e7 | ||
|
e898a9238d | ||
|
97a921048f | ||
|
b91577958a | ||
|
0997da84af | ||
|
66f94369fb | ||
|
f896f5e6ad | ||
|
9dad65e534 | ||
|
a7ef041f3d | ||
|
8785be9698 | ||
|
0ebfe731ca | ||
|
089c8d022e | ||
|
990ca0769b | ||
|
7449fd3a5f | ||
|
22f4be62d0 | ||
|
fb3960acc4 | ||
|
7c2add12b6 | ||
|
9327cd0d31 | ||
|
7fea10ffd5 | ||
|
bdc20bcabb | ||
|
6a34cf926e | ||
|
30cc0e9a8d | ||
|
6df3cffeee | ||
|
e5bffdb889 | ||
|
c2316e8f3d | ||
|
19247de2b8 | ||
|
f91ac5f04d | ||
|
0db2a4235f | ||
|
890ca26c78 | ||
|
b3fbd2358b | ||
|
5dbc5e0f4a | ||
|
84bf5b272c | ||
|
b5ce514397 | ||
|
7af6d5914d | ||
|
40942e2539 | ||
|
2139f2efa0 | ||
|
9994f86954 | ||
|
c47347e07d | ||
|
dff79dd021 | ||
|
95143d8c93 |
5
.commitlintrc
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"extends": [
|
||||||
|
"@commitlint/config-conventional"
|
||||||
|
]
|
||||||
|
}
|
4
.env.example
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
POSTGRES_USER=your_postgres_user
|
||||||
|
POSTGRES_PASSWORD=your_postgres_password
|
||||||
|
POSTGRES_DB=your_database_name
|
||||||
|
VALKEY_PASSWORD=your_valkey_password
|
3
.github/FUNDING.yml
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
github: ahmadk953
|
||||||
|
patreon: poixpixel
|
||||||
|
thanks_dev: u/gh/ahmadk953
|
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
|
@ -12,7 +12,7 @@ jobs:
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node-version: [21.x]
|
node-version: [23.x]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
2
.gitignore
vendored
|
@ -2,5 +2,7 @@ target/
|
||||||
node_modules/
|
node_modules/
|
||||||
drizzle/
|
drizzle/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
certs/
|
||||||
config.json
|
config.json
|
||||||
|
.env
|
||||||
.yarn
|
.yarn
|
1
.husky/commit-msg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
yarn dlx commitlint --edit \
|
1
.husky/pre-commit
Normal file
|
@ -0,0 +1 @@
|
||||||
|
yarn lint-staged
|
1
.husky/pre-push
Normal file
|
@ -0,0 +1 @@
|
||||||
|
yarn compile
|
12
.lintstagedrc.mjs
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import path from 'path';
|
||||||
|
import process from 'process';
|
||||||
|
|
||||||
|
const buildEslintCommand = (filenames) =>
|
||||||
|
`eslint ${filenames.map((f) => path.relative(process.cwd(), f)).join(' ')}`;
|
||||||
|
|
||||||
|
const prettierCommand = 'prettier --write';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
'*.{js,mjs,ts,mts}': [prettierCommand, buildEslintCommand],
|
||||||
|
'*.json': [prettierCommand],
|
||||||
|
};
|
|
@ -4,6 +4,8 @@ drizzle/
|
||||||
.vscode/
|
.vscode/
|
||||||
.github/
|
.github/
|
||||||
.yarn/
|
.yarn/
|
||||||
|
docs/
|
||||||
|
certs/
|
||||||
config.json
|
config.json
|
||||||
config.example.json
|
config.example.json
|
||||||
package.json
|
package.json
|
||||||
|
|
26
.vscode/launch.json
vendored
|
@ -1,26 +0,0 @@
|
||||||
{
|
|
||||||
"version": "0.1.0",
|
|
||||||
"configurations": [
|
|
||||||
{
|
|
||||||
"name": "Build and Run",
|
|
||||||
"type": "node",
|
|
||||||
"request": "launch",
|
|
||||||
"program": "${workspaceFolder}/target/_.cjs",
|
|
||||||
"preLaunchTask": "build",
|
|
||||||
"skipFiles": ["<node_internals>/**"],
|
|
||||||
"outFiles": ["${workspaceFolder}/target/**/*.cjs"]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"tasks": [
|
|
||||||
{
|
|
||||||
"label": "build",
|
|
||||||
"type": "shell",
|
|
||||||
"command": "node",
|
|
||||||
"args": ["${workspaceFolder}/build/compile.js"],
|
|
||||||
"group": {
|
|
||||||
"kind": "build",
|
|
||||||
"isDefault": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
14
.vscode/tasks.json
vendored
|
@ -1,14 +0,0 @@
|
||||||
{
|
|
||||||
"tasks": [
|
|
||||||
{
|
|
||||||
"label": "build",
|
|
||||||
"type": "shell",
|
|
||||||
"command": "node",
|
|
||||||
"args": ["${workspaceFolder}/build/compile.js"],
|
|
||||||
"group": {
|
|
||||||
"kind": "build",
|
|
||||||
"isDefault": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
28
README.md
|
@ -1,14 +1,38 @@
|
||||||
# Poixpixel's Discord Bot
|
# Poixpixel's Discord Bot
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> This Discord bot is not production ready and everything is subject to change
|
> This Discord bot is not production ready and is still in a testing state.
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> Want to see the bot in action? [Join our Discord server](https://discord.gg/KRTGjxx7gY).
|
||||||
|
|
||||||
|
## Documentation & Setup Instructions
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> Documentation is still under construction. Expect incomplete and undocumented features.
|
||||||
|
|
||||||
|
All documentation and setup instructions can be found at [https://docs.poixpixel.ahmadk953.org/](https://docs.poixpixel.ahmadk953.org/?utm_source=github&utm_medium=readme&utm_campaign=repository&utm_content=docs_link)
|
||||||
|
|
||||||
## Development Commands
|
## Development Commands
|
||||||
|
|
||||||
Install Dependencies: ``yarn install``
|
Install Dependencies: ``yarn install``
|
||||||
|
|
||||||
|
Lint: ``yarn lint``
|
||||||
|
|
||||||
|
Check Formatting: ``yarn format``
|
||||||
|
|
||||||
|
Fix Formatting: ``yarn format:fix``
|
||||||
|
|
||||||
Compile: ``yarn compile``
|
Compile: ``yarn compile``
|
||||||
|
|
||||||
Start: ``yarn target``
|
Start: ``yarn target``
|
||||||
|
|
||||||
Build & Start: ``yarn start``
|
Undeploy All Commands: ``yarn undeploy-commands``
|
||||||
|
|
||||||
|
Build & Start (dev): ``yarn start:dev``
|
||||||
|
|
||||||
|
Build & Start without Command Deployment (dev): ``yarn start:dev:no-deploy``
|
||||||
|
|
||||||
|
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-Regular.ttf
Normal file
BIN
assets/images/trophy.png
Normal file
After Width: | Height: | Size: 3.4 KiB |
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
@ -1,16 +1,63 @@
|
||||||
{
|
{
|
||||||
"token": "DISCORD_BOT_API_KEY",
|
"token": "DISCORD_BOT_TOKEN",
|
||||||
"clientId": "DISCORD_BOT_ID",
|
"clientId": "DISCORD_BOT_ID",
|
||||||
"guildId": "DISCORD_SERVER_ID",
|
"guildId": "DISCORD_SERVER_ID",
|
||||||
"dbConnectionString": "POSTGRESQL_CONNECTION_STRING",
|
"serverInvite": "DISCORD_SERVER_INVITE_LINK",
|
||||||
"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": {
|
"channels": {
|
||||||
"welcome": "WELCOME_CHANNEL_ID",
|
"welcome": "WELCOME_CHANNEL_ID",
|
||||||
"logs": "LOG_CHAANNEL_ID"
|
"logs": "LOG_CHANNEL_ID",
|
||||||
|
"counting": "COUNTING_CHANNEL_ID",
|
||||||
|
"factOfTheDay": "FACT_OF_THE_DAY_CHANNEL_ID",
|
||||||
|
"factApproval": "FACT_APPROVAL_CHANNEL_ID",
|
||||||
|
"advancements": "ADVANCEMENTS_CHANNEL_ID"
|
||||||
},
|
},
|
||||||
"roles": {
|
"roles": {
|
||||||
"joinRoles": [
|
"joinRoles": [
|
||||||
"JOIN_ROLE_IDS"
|
"JOIN_ROLE_IDS"
|
||||||
]
|
],
|
||||||
|
"levelRoles": [
|
||||||
|
{
|
||||||
|
"level": "LEVEL_NUMBER",
|
||||||
|
"roleId": "ROLE_ID"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "LEVEL_NUMBER",
|
||||||
|
"roleId": "ROLE_ID"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "LEVEL_NUMBER",
|
||||||
|
"roleId": "ROLE_ID"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"staffRoles": [
|
||||||
|
{
|
||||||
|
"name": "ROLE_NAME",
|
||||||
|
"roleId": "ROLE_ID"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ROLE_NAME",
|
||||||
|
"roleId": "ROLE_ID"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ROLE_NAME",
|
||||||
|
"roleId": "ROLE_ID"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"factPingRole": "FACT_OF_THE_DAY_ROLE_ID"
|
||||||
|
},
|
||||||
|
"leveling": {
|
||||||
|
"xpCooldown": "XP_COOLDOWN_IN_SECONDS",
|
||||||
|
"minXpAwarded": "MINIMUM_XP_AWARDED",
|
||||||
|
"maxXpAwarded": "MAXIMUM_XP_AWARDED"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
62
docker-compose.yml
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
container_name: postgres
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
volumes:
|
||||||
|
- ./certs/psql-server.crt:/var/lib/postgresql/server.crt:ro
|
||||||
|
- ./certs/psql-server.key:/var/lib/postgresql/server.key:ro
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- '5432:5432'
|
||||||
|
command: >
|
||||||
|
postgres
|
||||||
|
-c ssl=on
|
||||||
|
-c ssl_cert_file=/var/lib/postgresql/server.crt
|
||||||
|
-c ssl_key_file=/var/lib/postgresql/server.key
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}']
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
valkey:
|
||||||
|
image: valkey/valkey:8-alpine
|
||||||
|
container_name: valkey
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- '6379:6379'
|
||||||
|
volumes:
|
||||||
|
- ./certs/cache-server.crt:/certs/server.crt:ro
|
||||||
|
- ./certs/cache-server.key:/certs/server.key:ro
|
||||||
|
- ./certs/cache-ca.crt:/certs/ca.crt:ro
|
||||||
|
- valkey_data:/data
|
||||||
|
command: >
|
||||||
|
valkey-server
|
||||||
|
--requirepass ${VALKEY_PASSWORD}
|
||||||
|
--tls-port 6379
|
||||||
|
--port 0
|
||||||
|
--tls-cert-file /certs/server.crt
|
||||||
|
--tls-key-file /certs/server.key
|
||||||
|
--tls-ca-cert-file /certs/ca.crt
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'valkey-cli', '-a', '${VALKEY_PASSWORD}', 'ping']
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
valkey_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
backend:
|
||||||
|
driver: bridge
|
BIN
docs/bot/.gitbook/assets/AdvancedSettingsTab.png
Normal file
After Width: | Height: | Size: 118 KiB |
BIN
docs/bot/.gitbook/assets/AuthorizeDiscordBot.png
Normal file
After Width: | Height: | Size: 1.2 MiB |
BIN
docs/bot/.gitbook/assets/BasicBotConfigOptions.png
Normal file
After Width: | Height: | Size: 42 KiB |
BIN
docs/bot/.gitbook/assets/BotAddedSuccessMessage.png
Normal file
After Width: | Height: | Size: 1.2 MiB |
BIN
docs/bot/.gitbook/assets/BotHomePage.png
Normal file
After Width: | Height: | Size: 163 KiB |
BIN
docs/bot/.gitbook/assets/BotOptions.png
Normal file
After Width: | Height: | Size: 117 KiB |
BIN
docs/bot/.gitbook/assets/BotOptionsComplete.png
Normal file
After Width: | Height: | Size: 84 KiB |
BIN
docs/bot/.gitbook/assets/BotPage.png
Normal file
After Width: | Height: | Size: 1.1 MiB |
BIN
docs/bot/.gitbook/assets/BotTab.png
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
docs/bot/.gitbook/assets/BotToken.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
docs/bot/.gitbook/assets/ClientIDCopy.png
Normal file
After Width: | Height: | Size: 30 KiB |
BIN
docs/bot/.gitbook/assets/CopyChannelID.png
Normal file
After Width: | Height: | Size: 41 KiB |
BIN
docs/bot/.gitbook/assets/CopyGeneratedOAuth2URL.png
Normal file
After Width: | Height: | Size: 18 KiB |
BIN
docs/bot/.gitbook/assets/CopyRoleID.png
Normal file
After Width: | Height: | Size: 101 KiB |
BIN
docs/bot/.gitbook/assets/CopyServerID.png
Normal file
After Width: | Height: | Size: 48 KiB |
BIN
docs/bot/.gitbook/assets/CreateApplicationDialogue.png
Normal file
After Width: | Height: | Size: 20 KiB |
BIN
docs/bot/.gitbook/assets/DeveloperModeToggle.png
Normal file
After Width: | Height: | Size: 80 KiB |
BIN
docs/bot/.gitbook/assets/DiscordApplicationsPage.png
Normal file
After Width: | Height: | Size: 54 KiB |
BIN
docs/bot/.gitbook/assets/DiscordApplicationsPageMarkedUp.png
Normal file
After Width: | Height: | Size: 68 KiB |
BIN
docs/bot/.gitbook/assets/DockerDownloadPageMarkedUp.png
Normal file
After Width: | Height: | Size: 238 KiB |
BIN
docs/bot/.gitbook/assets/DockerDownloadPageWindows.png
Normal file
After Width: | Height: | Size: 232 KiB |
BIN
docs/bot/.gitbook/assets/GitWindowsDownloadPage.png
Normal file
After Width: | Height: | Size: 120 KiB |
BIN
docs/bot/.gitbook/assets/GitWindowsDownloadPageMarkedUp.png
Normal file
After Width: | Height: | Size: 116 KiB |
BIN
docs/bot/.gitbook/assets/InstallationCompleteOptions.png
Normal file
After Width: | Height: | Size: 40 KiB |
BIN
docs/bot/.gitbook/assets/InstallationPage.png
Normal file
After Width: | Height: | Size: 118 KiB |
BIN
docs/bot/.gitbook/assets/InstallationTab.png
Normal file
After Width: | Height: | Size: 31 KiB |
BIN
docs/bot/.gitbook/assets/InviteBotServerSelect.png
Normal file
After Width: | Height: | Size: 1.2 MiB |
BIN
docs/bot/.gitbook/assets/InviteDiscordBotDialogue.png
Normal file
After Width: | Height: | Size: 1.2 MiB |
BIN
docs/bot/.gitbook/assets/OAuth2Page.png
Normal file
After Width: | Height: | Size: 134 KiB |
BIN
docs/bot/.gitbook/assets/OAuth2Tab.png
Normal file
After Width: | Height: | Size: 31 KiB |
BIN
docs/bot/.gitbook/assets/OAuth2URLGenerator.png
Normal file
After Width: | Height: | Size: 108 KiB |
BIN
docs/bot/.gitbook/assets/OAuth2URLGeneratorConfiguration.png
Normal file
After Width: | Height: | Size: 104 KiB |
BIN
docs/bot/.gitbook/assets/ResetBotTokenButton.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
docs/bot/.gitbook/assets/ResetBotTokenDialogue.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
docs/bot/.gitbook/assets/ServerSettings.png
Normal file
After Width: | Height: | Size: 57 KiB |
BIN
docs/bot/.gitbook/assets/SettingsIcon.png
Normal file
After Width: | Height: | Size: 72 KiB |
30
docs/bot/README.md
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
---
|
||||||
|
icon: house
|
||||||
|
layout:
|
||||||
|
title:
|
||||||
|
visible: true
|
||||||
|
description:
|
||||||
|
visible: false
|
||||||
|
tableOfContents:
|
||||||
|
visible: true
|
||||||
|
outline:
|
||||||
|
visible: true
|
||||||
|
pagination:
|
||||||
|
visible: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Home
|
||||||
|
|
||||||
|
{% hint style="warning" %}
|
||||||
|
This documentation is still under construction. Expect rough edges and undocumented features
|
||||||
|
{% endhint %}
|
||||||
|
|
||||||
|
{% hint style="info" %}
|
||||||
|
This documentation is meant for people who want to use Poixpixel's Discord Bot on their own Discord server
|
||||||
|
{% endhint %}
|
||||||
|
|
||||||
|
Welcome to the Poixpixel Discord Bot's documentation! Here, you'll find information on getting started with the bot as well as documentation on all of its commands and features.
|
||||||
|
|
||||||
|
### Jump right in
|
||||||
|
|
||||||
|
<table data-view="cards"><thead><tr><th></th><th></th><th data-hidden data-card-cover data-type="files"></th><th data-hidden></th><th data-hidden data-card-target data-type="content-ref"></th></tr></thead><tbody><tr><td><strong>Getting Started</strong></td><td>Get started by setting up the bot</td><td></td><td></td><td><a href="broken-reference">Broken link</a></td></tr><tr><td><strong>Basics</strong></td><td>Learn the basics of the bot</td><td></td><td></td><td><a href="broken-reference">Broken link</a></td></tr><tr><td><strong>Contributing & Development</strong></td><td>Information for developers and contributors</td><td></td><td></td><td><a href="broken-reference">Broken link</a></td></tr></tbody></table>
|
21
docs/bot/SUMMARY.md
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
# Table of contents
|
||||||
|
|
||||||
|
* [Home](README.md)
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
* [Quickstart](getting-started/quickstart/README.md)
|
||||||
|
* [Self-Hosting](getting-started/quickstart/self-hosting.md)
|
||||||
|
* [Using a Cloud Provider](getting-started/quickstart/using-a-cloud-provider.md)
|
||||||
|
* [Basic Configuration](getting-started/basic-configuration.md)
|
||||||
|
|
||||||
|
## Basics
|
||||||
|
|
||||||
|
* [Updating the Bot](basics/updating-the-bot.md)
|
||||||
|
* [Configuration Options](basics/configuration-options.md)
|
||||||
|
|
||||||
|
## Developers
|
||||||
|
|
||||||
|
* [Introduction](developers/introduction.md)
|
||||||
|
* [Contribution Guidelines](developers/contribution-guidelines.md)
|
||||||
|
* [Licensing Information](developers/licensing-information.md)
|
7
docs/bot/basics/configuration-options.md
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
---
|
||||||
|
description: Learn about all the configuration options and what they do
|
||||||
|
icon: list
|
||||||
|
---
|
||||||
|
|
||||||
|
# Configuration Options
|
||||||
|
|
6
docs/bot/basics/updating-the-bot.md
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
---
|
||||||
|
icon: pen-to-square
|
||||||
|
---
|
||||||
|
|
||||||
|
# Updating the Bot
|
||||||
|
|
6
docs/bot/developers/contribution-guidelines.md
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
---
|
||||||
|
icon: clipboard-list
|
||||||
|
---
|
||||||
|
|
||||||
|
# Contribution Guidelines
|
||||||
|
|
6
docs/bot/developers/introduction.md
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
---
|
||||||
|
icon: signs-post
|
||||||
|
---
|
||||||
|
|
||||||
|
# Introduction
|
||||||
|
|
17
docs/bot/developers/licensing-information.md
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
---
|
||||||
|
icon: scale-unbalanced
|
||||||
|
layout:
|
||||||
|
title:
|
||||||
|
visible: true
|
||||||
|
description:
|
||||||
|
visible: false
|
||||||
|
tableOfContents:
|
||||||
|
visible: true
|
||||||
|
outline:
|
||||||
|
visible: true
|
||||||
|
pagination:
|
||||||
|
visible: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Licensing Information
|
||||||
|
|
7
docs/bot/getting-started/basic-configuration.md
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
---
|
||||||
|
description: Basic bot configuration options
|
||||||
|
icon: sliders
|
||||||
|
---
|
||||||
|
|
||||||
|
# Basic Configuration
|
||||||
|
|
216
docs/bot/getting-started/quickstart/README.md
Normal file
|
@ -0,0 +1,216 @@
|
||||||
|
---
|
||||||
|
description: Get started with the Discord bot
|
||||||
|
icon: bullseye-arrow
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quickstart
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
* **A Database & Cache**: Use **Valkey** or **Redis** for caching (we use Valkey in this guide). The main database must be **PostgreSQL**.
|
||||||
|
* **Server**: A server or computer to host the bot, preferably running Linux.
|
||||||
|
* **Skills**: Basic knowledge of the command line and managing servers.
|
||||||
|
* **Permissions**: The **Manage Server** permission in the Discord server where you want to add the bot.
|
||||||
|
* **Discord Developer Dashboard** access.
|
||||||
|
* A Discord account (obviously).
|
||||||
|
|
||||||
|
## Step 0: Chose Your Hosting Method
|
||||||
|
|
||||||
|
You can either choose to host everything yourself, or you can use a cloud provider to hose everything for you.
|
||||||
|
|
||||||
|
{% hint style="info" %}
|
||||||
|
We recommend hosting everything in the cloud since it's easier for beginners. However, if you have a spare computer or server and the knowledge to host it yourself, we suggest you do that since its cheaper than paying a cloud provider to host it all for you.
|
||||||
|
{% endhint %}
|
||||||
|
|
||||||
|
## Step 1: Basic Setup and Preparation
|
||||||
|
|
||||||
|
After deciding on how you want to host the bot and its resources, move onto the basic setup and preparation outlined below.
|
||||||
|
|
||||||
|
{% stepper %}
|
||||||
|
{% step %}
|
||||||
|
### Navigate to the Discord Developer Dashboard
|
||||||
|
|
||||||
|
[Click this link](https://discord.com/developers/applications) and sign into your discord account. Once you sign in, you should see a page like this:
|
||||||
|
|
||||||
|
<figure><img src="../../.gitbook/assets/DiscordApplicationsPage.png" alt="Applications page of the Discord Developer Dashboard"><figcaption><p>Discord Developer Dashboard Applications Page</p></figcaption></figure>
|
||||||
|
|
||||||
|
This is what's known as your applications page. This is where you'll see and manage all of your Discord bots and applications.
|
||||||
|
{% endstep %}
|
||||||
|
|
||||||
|
{% step %}
|
||||||
|
### Create a new application
|
||||||
|
|
||||||
|
Click the button that says, "New Application".
|
||||||
|
|
||||||
|
<figure><img src="../../.gitbook/assets/DiscordApplicationsPageMarkedUp.png" alt="Red arrow pointing to button on left navigation pane that says "New Application""><figcaption><p>Create a New Application</p></figcaption></figure>
|
||||||
|
|
||||||
|
After clicking the button, give you Discord Bot a name, click the check box, and then click "Create".
|
||||||
|
|
||||||
|
<figure><img src="../../.gitbook/assets/CreateApplicationDialogue.png" alt="Create application dialogue"><figcaption><p>Create Application Dialogue</p></figcaption></figure>
|
||||||
|
|
||||||
|
Once you click the "Create" button and complete the CAPTCHA, you should see a page like this:
|
||||||
|
|
||||||
|
<figure><img src="../../.gitbook/assets/BotHomePage.png" alt="Discord application overview page"><figcaption><p>Discord Application Overview Page</p></figcaption></figure>
|
||||||
|
|
||||||
|
This is the overview page for your Discord bot. Here, you can configure the app icon, the app name, and app description.
|
||||||
|
{% endstep %}
|
||||||
|
|
||||||
|
{% step %}
|
||||||
|
### Invite the bot to your server
|
||||||
|
|
||||||
|
In the left navigation pane, click the button that says, "OAuth2".
|
||||||
|
|
||||||
|
<figure><img src="../../.gitbook/assets/OAuth2Tab.png" alt="Red arrow pointing to button on left navigation pane that says "OAuth2""><figcaption><p>OAuth2 Button</p></figcaption></figure>
|
||||||
|
|
||||||
|
Once you click the button, you should see a page that looks like this:
|
||||||
|
|
||||||
|
<figure><img src="../../.gitbook/assets/OAuth2Page.png" alt="OAuth2 Page"><figcaption><p>OAuth2 Page</p></figcaption></figure>
|
||||||
|
|
||||||
|
Underneath the section that says, "Client Information" where it says "Client ID", click on the "Copy" button. Save this number as we'll need it for later.
|
||||||
|
|
||||||
|
<figure><img src="../../.gitbook/assets/ClientIDCopy.png" alt="Arrow pointing to "Copy" button under Client ID section"><figcaption><p>Client ID</p></figcaption></figure>
|
||||||
|
|
||||||
|
Next, scroll down to this section:
|
||||||
|
|
||||||
|
<figure><img src="../../.gitbook/assets/OAuth2URLGenerator.png" alt="OAuth2 URL Generator"><figcaption><p>OAuth2 URL Generator</p></figcaption></figure>
|
||||||
|
|
||||||
|
Check the checkbox next to where it says, "bot". Scroll down. Under the "Bot Permissions" section, click the checkbox for "Administrator" under the "General Permissions" section. Next, scroll down again and for the "Intergration Type" dropdown, make sure it says, "Guild Install". In the end, your configuration should look something like this:
|
||||||
|
|
||||||
|
<figure><img src="../../.gitbook/assets/OAuth2URLGeneratorConfiguration.png" alt="OAuth2 URL Generator Configuration Options"><figcaption><p>OAuth2 URL Generator Configuration</p></figcaption></figure>
|
||||||
|
|
||||||
|
Click "Copy" next to "Generated URL".\
|
||||||
|
|
||||||
|
|
||||||
|
<figure><img src="../../.gitbook/assets/CopyGeneratedOAuth2URL.png" alt="Copy generated URL"><figcaption><p>Copy Generated URL</p></figcaption></figure>
|
||||||
|
|
||||||
|
Open a new browser tab, pase in the link, and press <kbd>Enter</kbd>. You should then see a screen where you can invite the bot into a Discord server. Select your Discord server from the dropdown menu and click "continue".
|
||||||
|
|
||||||
|
{% hint style="info" %}
|
||||||
|
If you don't see the server you want to add the bot to, it's probably because you don't have the **Manage Server** permission in that Discord server
|
||||||
|
{% endhint %}
|
||||||
|
|
||||||
|
<figure><img src="../../.gitbook/assets/InviteBotServerSelect.png" alt="Invite discord bot to server dialogue"><figcaption><p>Invite Discord Bot Dialogue</p></figcaption></figure>
|
||||||
|
|
||||||
|
On the next screen, click "Authorize" and if prompted, complete multifactor authentication and the CAPTCHA.
|
||||||
|
|
||||||
|
<figure><img src="../../.gitbook/assets/AuthorizeDiscordBot.png" alt="Authorize Discord Bot"><figcaption><p>Authorize Discord Bot</p></figcaption></figure>
|
||||||
|
|
||||||
|
If everything was successful, you should see a success message like the one below.
|
||||||
|
|
||||||
|
<figure><img src="../../.gitbook/assets/BotAddedSuccessMessage.png" alt="Discord bot added successfully message"><figcaption><p>Success Message</p></figcaption></figure>
|
||||||
|
|
||||||
|
The discord bot was successfully added to your selected Discord server. You can now continue with the rest of the guide.
|
||||||
|
{% endstep %}
|
||||||
|
|
||||||
|
{% step %}
|
||||||
|
### Configure installation settings
|
||||||
|
|
||||||
|
Click the button on the left navigation pane that says, "Installation".
|
||||||
|
|
||||||
|
<figure><img src="../../.gitbook/assets/InstallationTab.png" alt="Red arrow pointing to button on left navigation pane that says "Installation""><figcaption><p>Installation Button</p></figcaption></figure>
|
||||||
|
|
||||||
|
After you click on the button, you'll be greeted by a page that look something like this:
|
||||||
|
|
||||||
|
<figure><img src="../../.gitbook/assets/InstallationPage.png" alt="Installation page"><figcaption><p>Installation Page</p></figcaption></figure>
|
||||||
|
|
||||||
|
First, uncheck the checkbox next to "User Install". Next, select "None" from the "Install Link" dropdown (click where it says "Discord Provided Link"). Finally, click "Save" at the bottom of the screen. When you're done, your screen should look like this:
|
||||||
|
|
||||||
|
<figure><img src="../../.gitbook/assets/InstallationCompleteOptions.png" alt="Updated installation options"><figcaption><p>Updated Installation Options</p></figcaption></figure>
|
||||||
|
|
||||||
|
Now, it's time to configure the actual Discord bot.
|
||||||
|
{% endstep %}
|
||||||
|
|
||||||
|
{% step %}
|
||||||
|
### Configure and get your bot's information
|
||||||
|
|
||||||
|
Click the button on the left navigation pane that says, "Bot".
|
||||||
|
|
||||||
|
<figure><img src="../../.gitbook/assets/BotTab.png" alt="Red arrow pointing to button on left navigation pane that says "Bot""><figcaption></figcaption></figure>
|
||||||
|
|
||||||
|
After clicking on the "Bot" tab, you'll see a page like this:
|
||||||
|
|
||||||
|
<figure><img src="../../.gitbook/assets/BotPage.png" alt="Discord application bot tab"><figcaption><p>Discord Application Bot Tab</p></figcaption></figure>
|
||||||
|
|
||||||
|
Here, you can configure things such as the username, banner, and icon of your bot. Scroll down to the section that looks like this:
|
||||||
|
|
||||||
|
<figure><img src="../../.gitbook/assets/BotOptions.png" alt="Discord bot options"><figcaption><p>Bot Options</p></figcaption></figure>
|
||||||
|
|
||||||
|
Deselect the "Public Bot" option and choose all other options. Make sure to click "Save". Your screen should resemble this:
|
||||||
|
|
||||||
|
<figure><img src="../../.gitbook/assets/BotOptionsComplete.png" alt="Updated bot options"><figcaption><p>Updated Bot Options</p></figcaption></figure>
|
||||||
|
|
||||||
|
{% hint style="info" %}
|
||||||
|
**Explanation for Selected Options:**
|
||||||
|
|
||||||
|
* Unselecting "Public Bot" restricts adding the bot to a server specifically to you, which is our intention.
|
||||||
|
* Enabling "Requires OAuth2 Code Grant" ensures the bot receives all its permissions before entering your server.
|
||||||
|
* By selecting all options under "Privileged Gateway Intents," the bot can view member presence statuses, manage members, and access message content.
|
||||||
|
{% endhint %}
|
||||||
|
|
||||||
|
Next, scroll back up to this section:
|
||||||
|
|
||||||
|
<figure><img src="../../.gitbook/assets/BasicBotConfigOptions.png" alt="Bot details and token configuration options"><figcaption><p>Basic Bot Configuration and Token Options</p></figcaption></figure>
|
||||||
|
|
||||||
|
Underneath the "Token" header, click on the button that says, "Reset Token".
|
||||||
|
|
||||||
|
<figure><img src="../../.gitbook/assets/ResetBotTokenButton.png" alt="Reset bot token button"><figcaption><p>Reset Token Button</p></figcaption></figure>
|
||||||
|
|
||||||
|
Click "Yes, do it!" on the dialogue that pops up.
|
||||||
|
|
||||||
|
<figure><img src="../../.gitbook/assets/ResetBotTokenDialogue.png" alt="Reset bot token confirmation dialogue"><figcaption><p>Reset Bot Token Dialogue</p></figcaption></figure>
|
||||||
|
|
||||||
|
Follow the multifactor authentication steps, and once complete, you should see a screen like this:
|
||||||
|
|
||||||
|
{% hint style="danger" %}
|
||||||
|
**WARNING: DO NOT SHARE YOUR BOT TOKEN WITH ANYONE. Treat your bot token like a password. If someone gets access to your bot's token, they'll have unrestricted access to your bot and Discord server, meaning they can do anything that they want. Store this token in a safe place as you won't get to see it again and will have to regenerate it.**
|
||||||
|
{% endhint %}
|
||||||
|
|
||||||
|
<figure><img src="../../.gitbook/assets/BotToken.png" alt="Discord bot token"><figcaption><p>Discord Bot Token</p></figcaption></figure>
|
||||||
|
|
||||||
|
Copy your bot token and save it somewhere safe. We'll need it later.
|
||||||
|
{% endstep %}
|
||||||
|
|
||||||
|
{% step %}
|
||||||
|
### Gather other information
|
||||||
|
|
||||||
|
If you've made it this far without getting lost, give yourself a pat on the back. Before we move onto the fun stuff, we have to gather one some last bits of information from our Discord server.
|
||||||
|
|
||||||
|
Head on over to [Discord](https://discord.com/app) and click on the settings icon next to your username.
|
||||||
|
|
||||||
|
<figure><img src="../../.gitbook/assets/SettingsIcon.png" alt="Red arrow pointing to settings button"><figcaption><p>Settings Icon</p></figcaption></figure>
|
||||||
|
|
||||||
|
Next, scroll down on the left navigation pane and click "Advanced".
|
||||||
|
|
||||||
|
<figure><img src="../../.gitbook/assets/AdvancedSettingsTab.png" alt="Red arrow pointing to button on left navigation pane that says "Advanced""><figcaption><p>Advanced Settings Button</p></figcaption></figure>
|
||||||
|
|
||||||
|
Find the option that says, "Developer Mode" and turn that on. Once you are done, your screen should look like this:
|
||||||
|
|
||||||
|
<figure><img src="../../.gitbook/assets/DeveloperModeToggle.png" alt="Developer mode toggle on"><figcaption><p>Developer Mode Toggle Turned On</p></figcaption></figure>
|
||||||
|
|
||||||
|
Exit settings and navigate to your Discord server. On the left server selector pane, right click on your Discord server and click the "Copy Server ID" button on the bottom of the options menu. This is what's known as your "Guild ID". Save this ID as we'll need it later.
|
||||||
|
|
||||||
|
<figure><img src="../../.gitbook/assets/CopyServerID.png" alt="Red arrow pointing to button that says "Copy Server ID""><figcaption><p>Copy Server ID Button</p></figcaption></figure>
|
||||||
|
|
||||||
|
Next, in your Discord server, right click on your logs channel and click the "Copy Channel ID" button. Repeat this for your welcome channel.
|
||||||
|
|
||||||
|
<figure><img src="../../.gitbook/assets/CopyChannelID.png" alt="Red arrow pointing to button that says "Copy Channel ID""><figcaption><p>Copy Channel ID Button</p></figcaption></figure>
|
||||||
|
|
||||||
|
Lastly, click on your server's name at the top and click on "Server Settings".
|
||||||
|
|
||||||
|
<figure><img src="../../.gitbook/assets/ServerSettings.png" alt="Server settings button"><figcaption><p>Server Settings Button</p></figcaption></figure>
|
||||||
|
|
||||||
|
Then, click on the "Roles" button in the left navigation pane and find the role(s) that you want to assign to people as soon as they join your server. Right click on each role and select "Copy Role ID". Save these ID's as we'll need them later when configuring the bot.
|
||||||
|
|
||||||
|
<figure><img src="../../.gitbook/assets/CopyRoleID.png" alt="Copy role ID for join roles"><figcaption><p>Copy Join Role ID</p></figcaption></figure>
|
||||||
|
|
||||||
|
We are now done with the preparation for our Discord bot. It's now time to setup and deploy the Discord bot and its services. Based on your decision in Step 0, click on the corresponding link to take you to the rest of the quick start guide.
|
||||||
|
{% endstep %}
|
||||||
|
{% endstepper %}
|
||||||
|
|
||||||
|
{% content-ref url="self-hosting.md" %}
|
||||||
|
[self-hosting.md](self-hosting.md)
|
||||||
|
{% endcontent-ref %}
|
||||||
|
|
||||||
|
{% content-ref url="using-a-cloud-provider.md" %}
|
||||||
|
[using-a-cloud-provider.md](using-a-cloud-provider.md)
|
||||||
|
{% endcontent-ref %}
|
655
docs/bot/getting-started/quickstart/self-hosting.md
Normal file
|
@ -0,0 +1,655 @@
|
||||||
|
---
|
||||||
|
description: Host the bot and its services yourself on a machine you own
|
||||||
|
icon: server
|
||||||
|
---
|
||||||
|
|
||||||
|
# Self-Hosting
|
||||||
|
|
||||||
|
## Step 2: Prepare your server
|
||||||
|
|
||||||
|
To set up the bot and its services, we first need to prepare our server. The steps may vary slightly depending on your server's operating system. Choose the instructions that match your OS. This guide assumes you have basic command line and server-managing skills.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
|
||||||
|
<summary>MacOS/Linux Instructions</summary>
|
||||||
|
|
||||||
|
## For Debian based Linux distributions using the x86-64 architecture 
|
||||||
|
|
||||||
|
First, let's update our package lists and upgrade our existing packages.
|
||||||
|
|
||||||
|
{% code fullWidth="false" %}
|
||||||
|
```bash
|
||||||
|
# Update package lists:
|
||||||
|
sudo apt-get update
|
||||||
|
|
||||||
|
# Upgrade existing packages:
|
||||||
|
sudo apt-get upgrade -y
|
||||||
|
```
|
||||||
|
{% endcode %}
|
||||||
|
|
||||||
|
Next, let's install Node.js and the Yarn package manager (this is mostly copied and pasted from [https://nodejs.org/en/download](https://nodejs.org/en/download)).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Download and install fnm:
|
||||||
|
curl -o- https://fnm.vercel.app/install | bash
|
||||||
|
|
||||||
|
# Activate fnm in our current shell:
|
||||||
|
source ~/.bashrc
|
||||||
|
|
||||||
|
# Download and install Node.js:
|
||||||
|
fnm install 22
|
||||||
|
|
||||||
|
# Verify the Node.js version:
|
||||||
|
node -v # Should print "v22.14.0".
|
||||||
|
|
||||||
|
# Download and install Yarn:
|
||||||
|
corepack enable yarn
|
||||||
|
|
||||||
|
# Verify Yarn version:
|
||||||
|
yarn -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Now, let's install git. This will make it so that we can clone the GitHub repository to our local machine (taken from [https://git-scm.com/downloads/linux](https://git-scm.com/downloads/linux)).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install git using built-in package manager:
|
||||||
|
sudo apt-get install git
|
||||||
|
|
||||||
|
# Verify git version:
|
||||||
|
git --version
|
||||||
|
```
|
||||||
|
|
||||||
|
Let's also install OpenSSL as we'll need it to generate SSL certificates for our PostgreSQL and caching databases:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install OpenSSL:
|
||||||
|
sudo apt install openssl
|
||||||
|
|
||||||
|
# Verify OpenSSL version:
|
||||||
|
openssl version
|
||||||
|
```
|
||||||
|
|
||||||
|
Next, let's install the pm2 process manager. This is how we'll run the Discord bot in a production environment (instructions from [https://pm2.keymetrics.io/docs/usage/quick-start/](https://pm2.keymetrics.io/docs/usage/quick-start/)).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install pm2 using npm
|
||||||
|
npm install pm2@latest -g
|
||||||
|
|
||||||
|
# or install pm2 using yarn:
|
||||||
|
yarn global add pm2
|
||||||
|
|
||||||
|
# Verify pm2 version:
|
||||||
|
pm2 -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Finally, let's install Docker and Docker Compose. These tools enable us to efficiently manage our PostgreSQL and caching databases with added flexibility, reducing complex configurations and simplifying overall management. Instructions are taken from here: [https://docs.docker.com/engine/install/debian/](https://docs.docker.com/engine/install/debian/) (if you're using Ubuntu, you can go straight to here: [https://docs.docker.com/engine/install/ubuntu/](https://docs.docker.com/engine/install/ubuntu/)) and here: [https://docs.docker.com/engine/install/linux-postinstall/](https://docs.docker.com/engine/install/linux-postinstall/).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Uninstall conflicting packages:
|
||||||
|
for pkg in docker.io docker-doc docker-compose podman-docker containerd runc; do sudo apt-get remove $pkg; done
|
||||||
|
|
||||||
|
# Clean up unused packages:
|
||||||
|
sudo apt autoremove
|
||||||
|
|
||||||
|
# Add Docker's official GPG key:
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install ca-certificates curl
|
||||||
|
sudo install -m 0755 -d /etc/apt/keyrings
|
||||||
|
sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
|
||||||
|
sudo chmod a+r /etc/apt/keyrings/docker.asc
|
||||||
|
|
||||||
|
# Add the repository to Apt sources (if you get errors on this step, check the official Docker documentation):
|
||||||
|
echo \
|
||||||
|
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \
|
||||||
|
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
|
||||||
|
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||||
|
sudo apt-get update
|
||||||
|
|
||||||
|
# Install the latest version of Docker and its tools:
|
||||||
|
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||||
|
|
||||||
|
# Start Docker (if it's not already running):
|
||||||
|
sudo systemctl start docker
|
||||||
|
|
||||||
|
# Configure Docker to start on boot:
|
||||||
|
sudo systemctl enable docker.service
|
||||||
|
sudo systemctl enable containerd.service
|
||||||
|
|
||||||
|
# Make it so we don't have to use sudo to use Docker:
|
||||||
|
sudo groupadd docker
|
||||||
|
sudo usermod -aG docker $USER
|
||||||
|
# Either log out and log back into your user account or run this command:
|
||||||
|
newgrp docker
|
||||||
|
|
||||||
|
# If you get this error:
|
||||||
|
# WARNING: Error loading config file: /home/user/.docker/config.json -
|
||||||
|
# stat /home/user/.docker/config.json: permission denied
|
||||||
|
# Run these commands:
|
||||||
|
sudo chown "$USER":"$USER" /home/"$USER"/.docker -R
|
||||||
|
sudo chmod g+rwx "$HOME/.docker" -R
|
||||||
|
|
||||||
|
# Verify Docker version:
|
||||||
|
docker version
|
||||||
|
|
||||||
|
# Verify Docker Compose Version:
|
||||||
|
docker compose version
|
||||||
|
|
||||||
|
# Run an example Docker container to ensure it's working (this should print a whole bunch of information):
|
||||||
|
docker run hello-world
|
||||||
|
```
|
||||||
|
|
||||||
|
With all necessary tools installed and configured, we're ready to run the bot and its services.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
|
||||||
|
<summary>Windows Instructions</summary>
|
||||||
|
|
||||||
|
First, let's install Node.js and the Yarn package manager on our machine. Open PowerShell and type in the following commands (instructions taken from [https://nodejs.org/en/download](https://nodejs.org/en/download)):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Download and install fnm (you might have to restart your terminal to use fnm):
|
||||||
|
winget install Schniz.fnm
|
||||||
|
|
||||||
|
# Download and install Node.js:
|
||||||
|
fnm install 22
|
||||||
|
|
||||||
|
# Verify the Node.js version:
|
||||||
|
node -v # Should print "v22.14.0".
|
||||||
|
|
||||||
|
# Download and install Yarn:
|
||||||
|
corepack enable yarn
|
||||||
|
|
||||||
|
# Verify Yarn version:
|
||||||
|
yarn -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Next, let's install git. This will enable us to clone the GitHub repository to our local machine.
|
||||||
|
|
||||||
|
Open a web browser and head to [https://git-scm.com/downloads/win](https://git-scm.com/downloads/win). You should see a page like this:
|
||||||
|
|
||||||
|
<figure><img src="../../.gitbook/assets/GitWindowsDownloadPage.png" alt="Download page for git"><figcaption><p>Git Download Page</p></figcaption></figure>
|
||||||
|
|
||||||
|
Underneath the "Standalone Installer" section, click on the link that says "64-bit Git for Windows Setup."
|
||||||
|
|
||||||
|
<figure><img src="../../.gitbook/assets/GitWindowsDownloadPageMarkedUp.png" alt="Red arrow pointing to a link that says "64-bit Git for Windows Setup.""><figcaption><p>Git Download Page</p></figcaption></figure>
|
||||||
|
|
||||||
|
This should download the git installer to your machine. Once it's finished downloading, open the installer and follow the steps to install git. After git is finished installing, open PowerShell again and type in the following to verify that git was installed correctly:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Verify git version (you might have to relaunch your terminal if you're using and already open window):
|
||||||
|
git --version
|
||||||
|
```
|
||||||
|
|
||||||
|
Now, let's install the pm2 process manager. This is how we'll run the Discord bot in a production environment (instructions from [https://pm2.keymetrics.io/docs/usage/quick-start/](https://pm2.keymetrics.io/docs/usage/quick-start/)). Run the following in PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Install pm2 using npm:
|
||||||
|
npm install pm2@latest -g
|
||||||
|
|
||||||
|
# or install pm2 using yarn:
|
||||||
|
yarn global add pm2
|
||||||
|
|
||||||
|
# Verify pm2 version:
|
||||||
|
pm2 -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Finally, let's install Docker and Docker compose. These tools will make it so that we can run our PostgreSQL and caching databases without needing a separate Linux server to run them. All instructions are taken from here [https://medium.com/@piyushkashyap045/comprehensive-guide-installing-docker-and-docker-compose-on-windows-linux-and-macos-a022cf82ac0b](https://medium.com/@piyushkashyap045/comprehensive-guide-installing-docker-and-docker-compose-on-windows-linux-and-macos-a022cf82ac0b) and here [https://docs.docker.com/desktop/setup/install/windows-install/](https://docs.docker.com/desktop/setup/install/windows-install/).
|
||||||
|
|
||||||
|
First, open a PowerShell window as an Administrator and type in the following to install the WSL runtime:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Install the WSL runtime:
|
||||||
|
wsl --install
|
||||||
|
```
|
||||||
|
|
||||||
|
After the installation is complete, restart your computer.
|
||||||
|
|
||||||
|
Next, open a web browser and go to [https://docs.docker.com/desktop/setup/install/windows-install/](https://docs.docker.com/desktop/setup/install/windows-install/). You should see a page like this:
|
||||||
|
|
||||||
|
<figure><img src="../../.gitbook/assets/DockerDownloadPageWindows.png" alt="Docker download page for Windows"><figcaption><p>Docker Download Page for Windows</p></figcaption></figure>
|
||||||
|
|
||||||
|
Click on the button that says, "Docker Desktop for Windows - x86\_64".
|
||||||
|
|
||||||
|
<figure><img src="../../.gitbook/assets/DockerDownloadPageMarkedUp.png" alt="Red arrow pointing to a button that says "Docker Desktop for Windows - x86_64""><figcaption><p>Docker Download Page for Windows</p></figcaption></figure>
|
||||||
|
|
||||||
|
This should download the Docker installer to your machine. Once it's finished downloading, open the installer and follow the instructions to install Docker onto your machine. If it prompts you to use either Hyper-V or WSL for containers, choose WSL. Once the installation is finished, restart your computer.
|
||||||
|
|
||||||
|
 Finally, open your search bar and search for Docker. Click on the option that says, "Docker Desktop". Wait for Docker to start. Once it starts up, open a new PowerShell window and type in the following:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Verify Docker version:
|
||||||
|
docker --version
|
||||||
|
|
||||||
|
# Verify Docker Compose version:
|
||||||
|
docker compose version
|
||||||
|
|
||||||
|
# Run an example Docker container to ensure it's working (this should print a whole bunch of information):
|
||||||
|
docker run hello-world
|
||||||
|
```
|
||||||
|
|
||||||
|
With all necessary tools installed and configured, we're ready to run the bot and its services.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Step 3: Setup and configure the bot
|
||||||
|
|
||||||
|
Once our server is prepared, we can proceed to download and configure the bot by following these steps:
|
||||||
|
|
||||||
|
{% stepper %}
|
||||||
|
{% step %}
|
||||||
|
### Clone the bot's repository to your server
|
||||||
|
|
||||||
|
Open a terminal window and paste in the following:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the GitHub repository to our local machine:
|
||||||
|
git clone -b main --single-branch --depth 1 https://github.com/ahmadk953/poixpixel-discord-bot.git
|
||||||
|
|
||||||
|
# Change-Directory into the bot's directory:
|
||||||
|
cd poixpixel-discord-bot
|
||||||
|
```
|
||||||
|
|
||||||
|
Now, we can start configuring the bot
|
||||||
|
{% endstep %}
|
||||||
|
|
||||||
|
{% step %}
|
||||||
|
### Configure the bot
|
||||||
|
|
||||||
|
Run the following to copy the `config.example.json` file to a new file named `config.json`. This is where we'll store all our bot configuration options, including the bot's token.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy and rename config.example.json to config.json:
|
||||||
|
cp config.example.json config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Next, open the new `config.json` file in a text editor like vim or nano on Mac/Linux, or Visual Studio Code on Windows. When you open the file, it should look something like this:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "DISCORD_BOT_TOKEN",
|
||||||
|
"clientId": "DISCORD_BOT_ID",
|
||||||
|
"guildId": "DISCORD_SERVER_ID",
|
||||||
|
"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_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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To configure your bot, follow these steps:
|
||||||
|
|
||||||
|
1. Replace `DISCORD_BOT_TOKEN` with your bot's token.
|
||||||
|
2. Replace `DISCORD_BOT_ID` with your bot's Client ID.
|
||||||
|
3. Replace `DISCORD_SERVER_ID` with your server's ID.
|
||||||
|
4. Replace the following with corresponding IDs you've collected:
|
||||||
|
* `WELCOME_CHANNEL_ID`
|
||||||
|
* `LOG_CHANNEL_ID`
|
||||||
|
* `JOIN_ROLE_IDS`
|
||||||
|
|
||||||
|
After completing these replacements, your configuration should look like this:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "MTM*****************************",
|
||||||
|
"clientId": "1361135180999561387",
|
||||||
|
"guildId": "1237197296714907678",
|
||||||
|
"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": "1361198468340912319",
|
||||||
|
"logs": "1361198724864540693",
|
||||||
|
"counting": "COUNTING_CHANNEL_ID",
|
||||||
|
"factOfTheDay": "FACT_OF_THE_DAY_CHANNEL_ID",
|
||||||
|
"factApproval": "FACT_APPROVAL_CHANNEL_ID",
|
||||||
|
"advancements": "ADVANCEMENTS_CHANNEL_ID"
|
||||||
|
},
|
||||||
|
"roles": {
|
||||||
|
"joinRoles": [
|
||||||
|
"1361197760530874528",
|
||||||
|
"1362199066343375103"
|
||||||
|
],
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
We'll fill in the details for the caching and PostgreSQL databases later. Lastly, let's get the bot ready for when we eventually start it up.
|
||||||
|
{% endstep %}
|
||||||
|
|
||||||
|
{% step %}
|
||||||
|
### Setup and compile the source code
|
||||||
|
|
||||||
|
Open a terminal window in the projects root directory and run the following commands to install dependencies and compile the source code.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies (if asked to continue, press 'y' and hit enter for yes):
|
||||||
|
yarn install --immutable
|
||||||
|
|
||||||
|
# Invoke build tools:
|
||||||
|
yarn prepare
|
||||||
|
|
||||||
|
# Compile source code:
|
||||||
|
yarn compile
|
||||||
|
```
|
||||||
|
|
||||||
|
Now that we've finished preparing the bot, let's move onto setting up its resources
|
||||||
|
{% endstep %}
|
||||||
|
{% endstepper %}
|
||||||
|
|
||||||
|
## Step 4: Set up the PostgreSQL and caching databases
|
||||||
|
|
||||||
|
Now, it's time to set up the bot's services. Follow the steps below to set up the PostgreSQL and caching databases.
|
||||||
|
|
||||||
|
{% stepper %}
|
||||||
|
{% step %}
|
||||||
|
### Generate SSL certificates
|
||||||
|
|
||||||
|
{% hint style="info" %}
|
||||||
|
Note that this step might be a little tricky on Windows and that it's not fully tested. If anyone would like to fully test and contribute their finding, that would be extremely helpful.
|
||||||
|
{% endhint %}
|
||||||
|
|
||||||
|
If on Windows, type the following into your terminal. If you're on Linux/MacOS, you can skip this step.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Launch WSL:
|
||||||
|
wsl
|
||||||
|
|
||||||
|
# Head into the directory where the bot's files are located:
|
||||||
|
cd /mnt/DRIVELETTER/PATH/TO/DISCORD/BOT/DIRECTORY
|
||||||
|
```
|
||||||
|
|
||||||
|
Make sure to replace `DRIVELETTER/ATH/TO/DISCORD/BOT/DIRECTORY` with the path to your bot's directory as you normally would. For example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/c/Users/ahmad/Downloads/poixpixel-discord-bot
|
||||||
|
```
|
||||||
|
|
||||||
|
There's already a shell script in the project's directory that'll generate the SSL certificates for you. Just run the following commands to execute the script:
|
||||||
|
|
||||||
|
{% hint style="info" %}
|
||||||
|
Note that it's always a good idea to check scripts that you are about to execute from any source online for malicious code. If you don't understand what the script is doing or, don't know how to read bash scripts, you can always ask an AI tool to explain it for you. The source code for the script that we are about to execute can be found [here](../../../../generate-certs.sh). 
|
||||||
|
{% endhint %}
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Make the script executable:
|
||||||
|
chmod +x generate-certs.sh
|
||||||
|
|
||||||
|
# Run the script:
|
||||||
|
./generate-certs.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
{% hint style="info" %}
|
||||||
|
Note that these SSL certificates will expire after a year. Simply re-run the script to generate new ones and restart both the bot and the Docker containers.
|
||||||
|
{% endhint %}
|
||||||
|
|
||||||
|
Now that we have the SSL certificates set up, we can move onto configuring environment variables for our Docker containers. Windows users can now switch back to their normal terminals by simply closing and re-opening their terminals.
|
||||||
|
{% endstep %}
|
||||||
|
|
||||||
|
{% step %}
|
||||||
|
### Set up environment variables 
|
||||||
|
|
||||||
|
Run the following to copy the `.env.example` file to a new file named `.env`. This is where our database username and password, as well as our caching database's password will live.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy and rename .env.example to .env:
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Next, open the new `.env` file. You should see something like this:
|
||||||
|
|
||||||
|
```
|
||||||
|
POSTGRES_USER=your_postgres_user
|
||||||
|
POSTGRES_PASSWORD=your_postgres_password
|
||||||
|
POSTGRES_DB=your_database_name
|
||||||
|
VALKEY_PASSWORD=your_valkey_password
|
||||||
|
```
|
||||||
|
|
||||||
|
To configure your bot's resources, follow these steps:
|
||||||
|
|
||||||
|
1. Replace `your_postgres_user` with your desired username for connecting to the database.
|
||||||
|
2. Replace `your_postgres_password` with a secure password for the database user you just specified.
|
||||||
|
3. Substitute `your_database_name` with a name for the Postgres database that will store your bot's data.
|
||||||
|
4. Replace `your_valkey_password` with a password for your Valkey database, which serves as a caching database.
|
||||||
|
|
||||||
|
When you're done, your config file should look like this:
|
||||||
|
|
||||||
|
```
|
||||||
|
POSTGRES_USER=bot-user
|
||||||
|
POSTGRES_PASSWORD=password
|
||||||
|
POSTGRES_DB=bot-db
|
||||||
|
VALKEY_PASSWORD=password
|
||||||
|
```
|
||||||
|
|
||||||
|
With our bot's resources configured, let's launch their Docker containers.
|
||||||
|
{% endstep %}
|
||||||
|
|
||||||
|
{% step %}
|
||||||
|
### Spin up the Docker containers
|
||||||
|
|
||||||
|
This step is relatively simple; all you have to do is run the command below to start up the Docker containers
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Spin up the Docker containers:
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
And in case you need to stop the containers:
|
||||||
|
|
||||||
|
<pre class="language-bash"><code class="lang-bash"><strong># Stop the containers WITHOUT deleting and removing them:
|
||||||
|
</strong><strong>docker compose stop
|
||||||
|
</strong><strong>
|
||||||
|
</strong><strong># Stop the containers and DELETE/REMOVE THEM. Note your DATA WILL BE SAFE. This just deletes the actual Docker containers:
|
||||||
|
</strong><strong>docker compose down
|
||||||
|
</strong></code></pre>
|
||||||
|
|
||||||
|
Lastly, to restart the containers and view their logs, you can run the following commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Restart the Docker containers:
|
||||||
|
docker compose restart
|
||||||
|
|
||||||
|
# View Docker container logs:
|
||||||
|
docker compose logs
|
||||||
|
```
|
||||||
|
|
||||||
|
Once all the bot's resources are operational, let's complete the configuration and start it up.
|
||||||
|
{% endstep %}
|
||||||
|
{% endstepper %}
|
||||||
|
|
||||||
|
## Step 5: Finish bot configuration
|
||||||
|
|
||||||
|
Now that we have the bot's resources up and running, let's finish configuring it. Open the `config.json` file again and scroll to the section that look like this:
|
||||||
|
|
||||||
|
<pre class="language-json"><code class="lang-json"><strong>"database": {
|
||||||
|
</strong> "dbConnectionString": "POSTGRESQL_CONNECTION_STRING",
|
||||||
|
"maxRetryAttempts": "MAX_RETRY_ATTEMPTS",
|
||||||
|
"retryDelay": "RETRY_DELAY_IN_MS"
|
||||||
|
},
|
||||||
|
</code></pre>
|
||||||
|
|
||||||
|
You can use the following template to obtain the PostgreSQL connection string: `postgresql://username:password@localhost/database`.
|
||||||
|
|
||||||
|
Since the bot and the Postgres instance run on the same server, the format should be: `postgresql://username:password@localhost/database`.
|
||||||
|
|
||||||
|
Fill out the format as follows:
|
||||||
|
|
||||||
|
1. Replace `username` with the value from the `POSTGRES_USER` environment variable.
|
||||||
|
2. Replace `password` with the value from the `POSTGRES_PASSWORD` environment variable.
|
||||||
|
3. Replace `database` with the value from the `POSTGRES_DB` environment variable.
|
||||||
|
|
||||||
|
Once updated, it should look like this example: `postgresql://bot-user:password@localhost/bot-db`. Replace `POSTGRESQL_CONNECTION_STRING` with this value.
|
||||||
|
|
||||||
|
Lastly, replace `MAX_RETRY_ATTEMPTS` with the desired maximum number of connection attempts. Then, change `RETRY_DELAY_IN_MS` to specify the wait time in milliseconds between retries. You should now have something that look like this:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"database": {
|
||||||
|
"dbConnectionString": "postgresql://bot-user:password@localhost/bot-db",
|
||||||
|
"maxRetryAttempts": "5",
|
||||||
|
"retryDelay": "2000"
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
Next, scroll down to the section that looks like this:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"redis": {
|
||||||
|
"redisConnectionString": "REDIS_CONNECTION_STRING",
|
||||||
|
"retryAttempts": "RETRY_ATTEMPTS",
|
||||||
|
"initialRetryDelay": "INITIAL_RETRY_DELAY_IN_MS"
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
You can use the following template to obtain the Redis (in our case, Valkey since they're compatible with each other) connection string: `redis://username:password@host:port`.
|
||||||
|
|
||||||
|
In our case, this is the format we'll be using: `redis://default:password@localhost:6379`. All you have to do is replace `password` with the value you set for the `VALKEY_PASSWORD` environment variable. Replace `REDIS_CONNECTION_STRING` with this value.
|
||||||
|
|
||||||
|
Lastly, replace `RETRY_ATTEMPTS` with the desired maximum number of connection attempts. Then, change `INITIAL_RETRY_DELAY_IN_MS` to specify the wait time in milliseconds between retries. You should now have something that look like this:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"redis": {
|
||||||
|
"redisConnectionString": "redis://default:password@localhost:6379",
|
||||||
|
"retryAttempts": "3",
|
||||||
|
"initialRetryDelay": "3000"
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
Now, let's proceed to set up the tables in our database and start the bot.
|
||||||
|
|
||||||
|
## Step 6: Set up database tables
|
||||||
|
|
||||||
|
To set up the database tables, all you have to do is run the following commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate SQL migration file:
|
||||||
|
npx drizzle-kit generate
|
||||||
|
|
||||||
|
# Apply SQL migration to database:
|
||||||
|
npx drizzle-kit migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
Now, we can finally start the bot.
|
||||||
|
|
||||||
|
## Step 7: Start the bot
|
||||||
|
|
||||||
|
To start the bot, simply run the following:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start the bot in production mode:
|
||||||
|
yarn start:prod
|
||||||
|
```
|
||||||
|
|
||||||
|
To view the bot's logs, type in this command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# To view bot logs:
|
||||||
|
pm2 logs
|
||||||
|
```
|
||||||
|
|
||||||
|
To stop the bot, run this command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# To stop the bot:
|
||||||
|
pm2 stop poixpixel-discord-bot
|
||||||
|
```
|
||||||
|
|
||||||
|
Finally, go to your Discord server and test out the bot's commands to make sure that it's working. Once you have verified that it is working, you can move onto reading about some of the bot's basic configuration options linked below.
|
||||||
|
|
||||||
|
{% content-ref url="../basic-configuration.md" %}
|
||||||
|
[basic-configuration.md](../basic-configuration.md)
|
||||||
|
{% endcontent-ref %}
|
|
@ -0,0 +1,6 @@
|
||||||
|
---
|
||||||
|
icon: cloud
|
||||||
|
---
|
||||||
|
|
||||||
|
# Using a Cloud Provider
|
||||||
|
|
|
@ -1,14 +1,30 @@
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
import { defineConfig } from 'drizzle-kit';
|
import { defineConfig } from 'drizzle-kit';
|
||||||
|
|
||||||
const config = JSON.parse(fs.readFileSync('./config.json', 'utf8'));
|
const config = JSON.parse(fs.readFileSync('./config.json', 'utf8'));
|
||||||
const { dbConnectionString } = config;
|
const { database } = config;
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
out: './drizzle',
|
out: './drizzle',
|
||||||
schema: './src/db/schema.ts',
|
schema: './src/db/schema.ts',
|
||||||
dialect: 'postgresql',
|
dialect: 'postgresql',
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: dbConnectionString,
|
url: database.dbConnectionString,
|
||||||
|
ssl: (() => {
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
ca: fs.readFileSync(path.resolve('./certs/psql-ca.crt')),
|
||||||
|
key: fs.readFileSync(path.resolve('./certs/psql-client.key')),
|
||||||
|
cert: fs.readFileSync(path.resolve('./certs/psql-server.crt')),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
'Failed to load certificates for database, using insecure connection:',
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
})(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
43
generate-certs.sh
Executable file
|
@ -0,0 +1,43 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Get the Effective User ID
|
||||||
|
_uid="$(id -u)"
|
||||||
|
|
||||||
|
# Create the certificates directory
|
||||||
|
mkdir -p certs
|
||||||
|
|
||||||
|
# Generate PostgreSQL Certificates
|
||||||
|
openssl req -new -x509 -days 365 -nodes \
|
||||||
|
-out certs/psql-server.crt \
|
||||||
|
-keyout certs/psql-server.key \
|
||||||
|
-subj "/CN=localhost"
|
||||||
|
|
||||||
|
# Generate Valkey Certificates
|
||||||
|
openssl req -new -x509 -days 365 -nodes \
|
||||||
|
-out certs/cache-server.crt \
|
||||||
|
-keyout certs/cache-server.key \
|
||||||
|
-subj "/CN=localhost"
|
||||||
|
|
||||||
|
# Get CA Certificates
|
||||||
|
cp certs/psql-server.crt certs/psql-ca.crt
|
||||||
|
cp certs/cache-server.crt certs/cache-ca.crt
|
||||||
|
|
||||||
|
# Setup Permissions
|
||||||
|
chmod 0600 certs/psql-server.key
|
||||||
|
chmod 0600 certs/cache-server.key
|
||||||
|
|
||||||
|
# Assign Ownership
|
||||||
|
sudo chown 70:70 certs/psql-*.*
|
||||||
|
sudo chown 999:1000 certs/cache-*.*
|
||||||
|
|
||||||
|
# Get Client Keys
|
||||||
|
sudo cp certs/psql-server.key certs/psql-client.key
|
||||||
|
sudo cp certs/cache-server.key certs/cache-client.key
|
||||||
|
|
||||||
|
# Change Client Key Ownership
|
||||||
|
sudo chown $_uid:$_uid certs/psql-client.key
|
||||||
|
sudo chown $_uid:$_uid certs/cache-client.key
|
||||||
|
|
||||||
|
# Change Client Key Permissions
|
||||||
|
sudo chmod +r certs/psql-client.key
|
||||||
|
sudo chmod +r certs/cache-client.key
|
48
package.json
|
@ -9,34 +9,46 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"compile": "npx tsc",
|
"compile": "npx tsc",
|
||||||
"target": "node ./target/discord-bot.js",
|
"target": "node ./target/discord-bot.js",
|
||||||
"start": "yarn run compile && yarn run target",
|
"start:dev": "yarn run compile && yarn run target",
|
||||||
|
"start:dev:no-deploy": "cross-env SKIP_COMMAND_DEPLOY=true yarn run start:dev",
|
||||||
|
"start:prod": "yarn compile && pm2 start ./target/discord-bot.js --name poixpixel-discord-bot",
|
||||||
|
"restart": "pm2 restart poixpixel-discord-bot",
|
||||||
|
"undeploy-commands": "yarn compile && node --experimental-specifier-resolution=node ./target/util/undeployCommands.js",
|
||||||
"lint": "npx eslint ./src && npx tsc --noEmit",
|
"lint": "npx eslint ./src && npx tsc --noEmit",
|
||||||
"format": "prettier --check --ignore-path .prettierignore .",
|
"format": "prettier --check --ignore-path .prettierignore .",
|
||||||
"format:fix": "prettier --write --ignore-path .prettierignore ."
|
"format:fix": "prettier --write --ignore-path .prettierignore .",
|
||||||
|
"prepare": "ts-patch install -s && husky"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@napi-rs/canvas": "^0.1.68",
|
"@napi-rs/canvas": "^0.1.69",
|
||||||
"discord.js": "^14.18.0",
|
"discord.js": "^14.19.2",
|
||||||
"drizzle-orm": "^0.41.0",
|
"drizzle-orm": "^0.43.1",
|
||||||
"ioredis": "^5.6.0",
|
"ioredis": "^5.6.1",
|
||||||
"pg": "^8.14.1"
|
"pg": "^8.15.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@commitlint/cli": "^19.8.0",
|
||||||
|
"@commitlint/config-conventional": "^19.8.0",
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"@eslint/js": "^9.23.0",
|
"@eslint/js": "^9.25.1",
|
||||||
"@microsoft/eslint-formatter-sarif": "^3.1.0",
|
"@microsoft/eslint-formatter-sarif": "^3.1.0",
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.15.3",
|
||||||
"@types/pg": "^8.11.11",
|
"@types/pg": "^8.11.14",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.29.0",
|
"@typescript-eslint/eslint-plugin": "^8.31.1",
|
||||||
"@typescript-eslint/parser": "^8.29.0",
|
"@typescript-eslint/parser": "^8.31.1",
|
||||||
"drizzle-kit": "^0.30.6",
|
"cross-env": "^7.0.3",
|
||||||
"eslint": "^9.23.0",
|
"drizzle-kit": "^0.31.0",
|
||||||
"eslint-config-prettier": "^10.1.1",
|
"eslint": "^9.25.1",
|
||||||
|
"eslint-config-prettier": "^10.1.2",
|
||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
|
"husky": "^9.1.7",
|
||||||
|
"lint-staged": "^15.5.1",
|
||||||
"prettier": "3.5.3",
|
"prettier": "3.5.3",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsx": "^4.19.3",
|
"ts-patch": "^3.3.0",
|
||||||
"typescript": "^5.8.2"
|
"tsx": "^4.19.4",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"typescript-transform-paths": "^3.5.5"
|
||||||
},
|
},
|
||||||
"packageManager": "yarn@4.6.0"
|
"packageManager": "yarn@4.9.1"
|
||||||
}
|
}
|
||||||
|
|
926
src/commands/fun/achievement.ts
Normal file
|
@ -0,0 +1,926 @@
|
||||||
|
import {
|
||||||
|
SlashCommandBuilder,
|
||||||
|
EmbedBuilder,
|
||||||
|
ActionRowBuilder,
|
||||||
|
StringSelectMenuBuilder,
|
||||||
|
StringSelectMenuOptionBuilder,
|
||||||
|
PermissionFlagsBits,
|
||||||
|
ChatInputCommandInteraction,
|
||||||
|
StringSelectMenuInteraction,
|
||||||
|
ComponentType,
|
||||||
|
ButtonInteraction,
|
||||||
|
} from 'discord.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAllAchievements,
|
||||||
|
getUserAchievements,
|
||||||
|
awardAchievement,
|
||||||
|
createAchievement,
|
||||||
|
deleteAchievement,
|
||||||
|
removeUserAchievement,
|
||||||
|
} from '@/db/db.js';
|
||||||
|
import { announceAchievement } from '@/util/achievementManager.js';
|
||||||
|
import { createPaginationButtons } from '@/util/helpers.js';
|
||||||
|
|
||||||
|
const command = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('achievement')
|
||||||
|
.setDescription('Manage server achievements')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('create')
|
||||||
|
.setDescription('Create a new achievement')
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('name')
|
||||||
|
.setDescription('Name of the achievement')
|
||||||
|
.setRequired(true),
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('description')
|
||||||
|
.setDescription('Description of the achievement')
|
||||||
|
.setRequired(true),
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('requirement_type')
|
||||||
|
.setDescription('Type of requirement for this achievement')
|
||||||
|
.setRequired(true)
|
||||||
|
.addChoices(
|
||||||
|
{ name: 'Message Count', value: 'message_count' },
|
||||||
|
{ name: 'Level', value: 'level' },
|
||||||
|
{ name: 'Reactions', value: 'reactions' },
|
||||||
|
{ name: 'Command Usage', value: 'command_usage' },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.addIntegerOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('threshold')
|
||||||
|
.setDescription('Threshold value for completing the achievement')
|
||||||
|
.setRequired(true),
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('image_url')
|
||||||
|
.setDescription('URL for the achievement image (optional)')
|
||||||
|
.setRequired(false),
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('command_name')
|
||||||
|
.setDescription('Command name (only for command_usage type)')
|
||||||
|
.setRequired(false),
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('reward_type')
|
||||||
|
.setDescription('Type of reward (optional)')
|
||||||
|
.setRequired(false)
|
||||||
|
.addChoices(
|
||||||
|
{ name: 'XP', value: 'xp' },
|
||||||
|
{ name: 'Role', value: 'role' },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('reward_value')
|
||||||
|
.setDescription('Value of the reward (XP amount or role ID)')
|
||||||
|
.setRequired(false),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('delete')
|
||||||
|
.setDescription('Delete an achievement')
|
||||||
|
.addIntegerOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('id')
|
||||||
|
.setDescription('ID of the achievement to delete')
|
||||||
|
.setRequired(true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('award')
|
||||||
|
.setDescription('Award an achievement to a user')
|
||||||
|
.addUserOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('user')
|
||||||
|
.setDescription('User to award the achievement to')
|
||||||
|
.setRequired(true),
|
||||||
|
)
|
||||||
|
.addIntegerOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('achievement_id')
|
||||||
|
.setDescription('ID of the achievement to award')
|
||||||
|
.setRequired(true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('view')
|
||||||
|
.setDescription('View a users achievements')
|
||||||
|
.addUserOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('user')
|
||||||
|
.setDescription('User to view achievements for')
|
||||||
|
.setRequired(false),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('unaward')
|
||||||
|
.setDescription('Remove an achievement from a user')
|
||||||
|
.addUserOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('user')
|
||||||
|
.setDescription('User to remove the achievement from')
|
||||||
|
.setRequired(true),
|
||||||
|
)
|
||||||
|
.addIntegerOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('achievement_id')
|
||||||
|
.setDescription('ID of the achievement to remove')
|
||||||
|
.setRequired(true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
async execute(interaction: ChatInputCommandInteraction) {
|
||||||
|
if (!interaction.isChatInputCommand() || !interaction.guild) return;
|
||||||
|
|
||||||
|
await interaction.deferReply();
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
|
||||||
|
switch (subcommand) {
|
||||||
|
case 'create':
|
||||||
|
await handleCreateAchievement(interaction);
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
await handleDeleteAchievement(interaction);
|
||||||
|
break;
|
||||||
|
case 'award':
|
||||||
|
await handleAwardAchievement(interaction);
|
||||||
|
break;
|
||||||
|
case 'unaward':
|
||||||
|
await handleUnawardAchievement(interaction);
|
||||||
|
break;
|
||||||
|
case 'view':
|
||||||
|
await handleViewUserAchievements(interaction);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handleCreateAchievement(
|
||||||
|
interaction: ChatInputCommandInteraction,
|
||||||
|
) {
|
||||||
|
const name = interaction.options.getString('name')!;
|
||||||
|
const description = interaction.options.getString('description')!;
|
||||||
|
const imageUrl = interaction.options.getString('image_url');
|
||||||
|
const requirementType = interaction.options.getString('requirement_type')!;
|
||||||
|
const threshold = interaction.options.getInteger('threshold')!;
|
||||||
|
const commandName = interaction.options.getString('command_name');
|
||||||
|
const rewardType = interaction.options.getString('reward_type');
|
||||||
|
const rewardValue = interaction.options.getString('reward_value');
|
||||||
|
|
||||||
|
if (requirementType === 'command_usage' && !commandName) {
|
||||||
|
await interaction.editReply(
|
||||||
|
'Command name is required for command_usage type achievements.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rewardType && !rewardValue) {
|
||||||
|
await interaction.editReply(
|
||||||
|
`Reward value is required when setting a ${rewardType} reward.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requirement: any = {};
|
||||||
|
if (requirementType === 'command_usage' && commandName) {
|
||||||
|
requirement.command = commandName;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const achievement = await createAchievement({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
imageUrl: imageUrl || undefined,
|
||||||
|
requirementType,
|
||||||
|
threshold,
|
||||||
|
requirement,
|
||||||
|
rewardType: rewardType || undefined,
|
||||||
|
rewardValue: rewardValue || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (achievement) {
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setColor(0x00ff00)
|
||||||
|
.setTitle('Achievement Created')
|
||||||
|
.setDescription(`Successfully created achievement: **${name}**`)
|
||||||
|
.addFields(
|
||||||
|
{ name: 'ID', value: `${achievement.id}`, inline: true },
|
||||||
|
{ name: 'Type', value: requirementType, inline: true },
|
||||||
|
{ name: 'Threshold', value: `${threshold}`, inline: true },
|
||||||
|
{ name: 'Description', value: description },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rewardType && rewardValue) {
|
||||||
|
embed.addFields({
|
||||||
|
name: 'Reward',
|
||||||
|
value: `${rewardType === 'xp' ? `${rewardValue} XP` : `<@&${rewardValue}>`}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
} else {
|
||||||
|
await interaction.editReply('Failed to create achievement.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating achievement:', error);
|
||||||
|
await interaction.editReply(
|
||||||
|
'An error occurred while creating the achievement.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteAchievement(
|
||||||
|
interaction: ChatInputCommandInteraction,
|
||||||
|
) {
|
||||||
|
const achievementId = interaction.options.getInteger('id')!;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const success = await deleteAchievement(achievementId);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
await interaction.editReply(
|
||||||
|
`Achievement with ID ${achievementId} has been deleted.`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await interaction.editReply(
|
||||||
|
`Failed to delete achievement with ID ${achievementId}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting achievement:', error);
|
||||||
|
await interaction.editReply(
|
||||||
|
'An error occurred while deleting the achievement.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAwardAchievement(
|
||||||
|
interaction: ChatInputCommandInteraction,
|
||||||
|
) {
|
||||||
|
const user = interaction.options.getUser('user')!;
|
||||||
|
const achievementId = interaction.options.getInteger('achievement_id')!;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allAchievements = await getAllAchievements();
|
||||||
|
const achievement = allAchievements.find((a) => a.id === achievementId);
|
||||||
|
|
||||||
|
if (!achievement) {
|
||||||
|
await interaction.editReply(
|
||||||
|
`Achievement with ID ${achievementId} not found.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await awardAchievement(user.id, achievementId);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
await announceAchievement(interaction.guild!, user.id, achievement);
|
||||||
|
await interaction.editReply(
|
||||||
|
`Achievement "${achievement.name}" awarded to ${user}.`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await interaction.editReply(
|
||||||
|
'Failed to award achievement or user already has this achievement.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error awarding achievement:', error);
|
||||||
|
await interaction.editReply(
|
||||||
|
'An error occurred while awarding the achievement.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleViewUserAchievements(
|
||||||
|
interaction: ChatInputCommandInteraction,
|
||||||
|
) {
|
||||||
|
const targetUser = interaction.options.getUser('user') || interaction.user;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userAchievements = await getUserAchievements(targetUser.id);
|
||||||
|
const allAchievements = await getAllAchievements();
|
||||||
|
|
||||||
|
const totalAchievements = allAchievements.length;
|
||||||
|
const earnedCount = userAchievements.filter((ua) => ua.earnedAt).length;
|
||||||
|
const overallProgress =
|
||||||
|
totalAchievements > 0
|
||||||
|
? Math.round((earnedCount / totalAchievements) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if (totalAchievements === 0) {
|
||||||
|
await interaction.editReply(
|
||||||
|
'No achievements have been created on this server yet.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const earnedAchievements = userAchievements
|
||||||
|
.filter((ua) => {
|
||||||
|
return (
|
||||||
|
ua.earnedAt &&
|
||||||
|
ua.earnedAt !== null &&
|
||||||
|
ua.earnedAt !== undefined &&
|
||||||
|
new Date(ua.earnedAt).getTime() > 0
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((ua) => {
|
||||||
|
const achievementDef = allAchievements.find(
|
||||||
|
(a) => a.id === ua.achievementId,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...ua,
|
||||||
|
definition: achievementDef,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((a) => a.definition);
|
||||||
|
|
||||||
|
const inProgressAchievements = userAchievements
|
||||||
|
.filter((ua) => {
|
||||||
|
return (
|
||||||
|
(!ua.earnedAt ||
|
||||||
|
ua.earnedAt === null ||
|
||||||
|
ua.earnedAt === undefined ||
|
||||||
|
new Date(ua.earnedAt).getTime() <= 0) &&
|
||||||
|
(ua.progress ?? 0) > 0
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((ua) => {
|
||||||
|
const achievementDef = allAchievements.find(
|
||||||
|
(a) => a.id === ua.achievementId,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...ua,
|
||||||
|
definition: achievementDef,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((a) => a.definition);
|
||||||
|
|
||||||
|
const earnedAndInProgressIds = new Set(
|
||||||
|
userAchievements
|
||||||
|
.filter(
|
||||||
|
(ua) =>
|
||||||
|
(ua.progress ?? 0) > 0 ||
|
||||||
|
(ua.earnedAt && new Date(ua.earnedAt).getTime() > 0),
|
||||||
|
)
|
||||||
|
.map((ua) => ua.achievementId),
|
||||||
|
);
|
||||||
|
const availableAchievements = allAchievements
|
||||||
|
.filter((a) => !earnedAndInProgressIds.has(a.id))
|
||||||
|
.map((definition) => {
|
||||||
|
const existingEntry = userAchievements.find(
|
||||||
|
(ua) =>
|
||||||
|
ua.achievementId === definition.id &&
|
||||||
|
(ua.progress === 0 || ua.progress === null),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
achievementId: definition.id,
|
||||||
|
progress: existingEntry?.progress || 0,
|
||||||
|
definition,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
interface AchievementViewOption {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options: AchievementViewOption[] = [];
|
||||||
|
|
||||||
|
if (earnedAchievements.length > 0) {
|
||||||
|
options.push({
|
||||||
|
label: 'Earned Achievements',
|
||||||
|
value: 'earned',
|
||||||
|
count: earnedAchievements.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inProgressAchievements.length > 0) {
|
||||||
|
options.push({
|
||||||
|
label: 'In Progress',
|
||||||
|
value: 'progress',
|
||||||
|
count: inProgressAchievements.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (availableAchievements.length > 0) {
|
||||||
|
options.push({
|
||||||
|
label: 'Available Achievements',
|
||||||
|
value: 'available',
|
||||||
|
count: availableAchievements.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.length === 0) {
|
||||||
|
await interaction.editReply('No achievement data found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let initialOption = options[0].value;
|
||||||
|
for (const preferredType of ['earned', 'progress', 'available']) {
|
||||||
|
const found = options.find((opt) => opt.value === preferredType);
|
||||||
|
if (found) {
|
||||||
|
initialOption = preferredType;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialEmbedData =
|
||||||
|
initialOption === 'earned'
|
||||||
|
? { achievements: earnedAchievements, title: 'Earned Achievements' }
|
||||||
|
: initialOption === 'progress'
|
||||||
|
? {
|
||||||
|
achievements: inProgressAchievements,
|
||||||
|
title: 'Achievements In Progress',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
achievements: availableAchievements,
|
||||||
|
title: 'Available Achievements',
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialEmbed = createAchievementsEmbed(
|
||||||
|
initialEmbedData.achievements,
|
||||||
|
initialEmbedData.title,
|
||||||
|
targetUser,
|
||||||
|
overallProgress,
|
||||||
|
earnedCount,
|
||||||
|
totalAchievements,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Define pagination variables
|
||||||
|
const achievementsPerPage = 5;
|
||||||
|
let currentPage = 0;
|
||||||
|
|
||||||
|
const pages = splitAchievementsIntoPages(
|
||||||
|
initialEmbedData.achievements,
|
||||||
|
initialEmbedData.title,
|
||||||
|
targetUser,
|
||||||
|
overallProgress,
|
||||||
|
earnedCount,
|
||||||
|
totalAchievements,
|
||||||
|
achievementsPerPage,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create achievements type selector
|
||||||
|
const selectMenu =
|
||||||
|
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
||||||
|
new StringSelectMenuBuilder()
|
||||||
|
.setCustomId('achievement_view')
|
||||||
|
.setPlaceholder('Select achievement type')
|
||||||
|
.addOptions(
|
||||||
|
options.map((opt) =>
|
||||||
|
new StringSelectMenuOptionBuilder()
|
||||||
|
.setLabel(`${opt.label} (${opt.count})`)
|
||||||
|
.setValue(opt.value)
|
||||||
|
.setDefault(opt.value === initialOption),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create pagination buttons
|
||||||
|
const paginationRow = createPaginationButtons(pages.length, currentPage);
|
||||||
|
|
||||||
|
const message = await interaction.editReply({
|
||||||
|
embeds: [pages[currentPage]],
|
||||||
|
components: [selectMenu, ...(pages.length > 1 ? [paginationRow] : [])],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options.length <= 1 && pages.length <= 1) return;
|
||||||
|
|
||||||
|
// Create collector for both select menu and button interactions
|
||||||
|
const collector = message.createMessageComponentCollector({
|
||||||
|
componentType: ComponentType.StringSelect,
|
||||||
|
time: 60000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const buttonCollector = message.createMessageComponentCollector({
|
||||||
|
componentType: ComponentType.Button,
|
||||||
|
time: 60000,
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on('collect', async (i: StringSelectMenuInteraction) => {
|
||||||
|
if (i.user.id !== interaction.user.id) {
|
||||||
|
await i.reply({
|
||||||
|
content: 'You cannot use this menu.',
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await i.deferUpdate();
|
||||||
|
|
||||||
|
const selected = i.values[0];
|
||||||
|
let categoryPages;
|
||||||
|
let selectedAchievements;
|
||||||
|
|
||||||
|
if (selected === 'earned') {
|
||||||
|
selectedAchievements = earnedAchievements;
|
||||||
|
categoryPages = splitAchievementsIntoPages(
|
||||||
|
earnedAchievements,
|
||||||
|
'Earned Achievements',
|
||||||
|
targetUser,
|
||||||
|
overallProgress,
|
||||||
|
earnedCount,
|
||||||
|
totalAchievements,
|
||||||
|
achievementsPerPage,
|
||||||
|
);
|
||||||
|
} else if (selected === 'progress') {
|
||||||
|
selectedAchievements = inProgressAchievements;
|
||||||
|
categoryPages = splitAchievementsIntoPages(
|
||||||
|
inProgressAchievements,
|
||||||
|
'Achievements In Progress',
|
||||||
|
targetUser,
|
||||||
|
overallProgress,
|
||||||
|
earnedCount,
|
||||||
|
totalAchievements,
|
||||||
|
achievementsPerPage,
|
||||||
|
);
|
||||||
|
} else if (selected === 'available') {
|
||||||
|
selectedAchievements = availableAchievements;
|
||||||
|
categoryPages = splitAchievementsIntoPages(
|
||||||
|
availableAchievements,
|
||||||
|
'Available Achievements',
|
||||||
|
targetUser,
|
||||||
|
overallProgress,
|
||||||
|
earnedCount,
|
||||||
|
totalAchievements,
|
||||||
|
achievementsPerPage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (categoryPages && categoryPages.length > 0) {
|
||||||
|
currentPage = 0;
|
||||||
|
pages.splice(0, pages.length, ...categoryPages);
|
||||||
|
|
||||||
|
const updatedSelectMenu =
|
||||||
|
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
||||||
|
new StringSelectMenuBuilder()
|
||||||
|
.setCustomId('achievement_view')
|
||||||
|
.setPlaceholder('Select achievement type')
|
||||||
|
.addOptions(
|
||||||
|
options.map((opt) =>
|
||||||
|
new StringSelectMenuOptionBuilder()
|
||||||
|
.setLabel(`${opt.label} (${opt.count})`)
|
||||||
|
.setValue(opt.value)
|
||||||
|
.setDefault(opt.value === selected),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedPaginationRow = createPaginationButtons(
|
||||||
|
pages.length,
|
||||||
|
currentPage,
|
||||||
|
);
|
||||||
|
|
||||||
|
await i.editReply({
|
||||||
|
embeds: [pages[currentPage]],
|
||||||
|
components: [
|
||||||
|
updatedSelectMenu,
|
||||||
|
...(pages.length > 1 ? [updatedPaginationRow] : []),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
buttonCollector.on('collect', async (i: ButtonInteraction) => {
|
||||||
|
if (i.user.id !== interaction.user.id) {
|
||||||
|
await i.reply({
|
||||||
|
content: 'You cannot use these buttons.',
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await i.deferUpdate();
|
||||||
|
|
||||||
|
if (i.customId === 'first') {
|
||||||
|
currentPage = 0;
|
||||||
|
} else if (i.customId === 'prev') {
|
||||||
|
currentPage = Math.max(0, currentPage - 1);
|
||||||
|
} else if (i.customId === 'next') {
|
||||||
|
currentPage = Math.min(pages.length - 1, currentPage + 1);
|
||||||
|
} else if (i.customId === 'last') {
|
||||||
|
currentPage = pages.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedPaginationRow = createPaginationButtons(
|
||||||
|
pages.length,
|
||||||
|
currentPage,
|
||||||
|
);
|
||||||
|
|
||||||
|
await i.editReply({
|
||||||
|
embeds: [pages[currentPage]],
|
||||||
|
components: [selectMenu, updatedPaginationRow],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on('end', () => {
|
||||||
|
buttonCollector.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
buttonCollector.on('end', () => {
|
||||||
|
interaction.editReply({ components: [] }).catch((err) => {
|
||||||
|
console.error('Failed to edit reply after collector ended.', err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error viewing user achievements:', error);
|
||||||
|
await interaction.editReply(
|
||||||
|
'An error occurred while fetching user achievements.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle removing an achievement from a user
|
||||||
|
*/
|
||||||
|
async function handleUnawardAchievement(
|
||||||
|
interaction: ChatInputCommandInteraction,
|
||||||
|
) {
|
||||||
|
const user = interaction.options.getUser('user')!;
|
||||||
|
const achievementId = interaction.options.getInteger('achievement_id')!;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allAchievements = await getAllAchievements();
|
||||||
|
const achievement = allAchievements.find((a) => a.id === achievementId);
|
||||||
|
|
||||||
|
if (!achievement) {
|
||||||
|
await interaction.editReply(
|
||||||
|
`Achievement with ID ${achievementId} not found.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userAchievements = await getUserAchievements(user.id);
|
||||||
|
const earnedAchievement = userAchievements.find(
|
||||||
|
(ua) => ua.achievementId === achievementId && ua.earnedAt !== null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!earnedAchievement) {
|
||||||
|
await interaction.editReply(
|
||||||
|
`${user.username} has not earned the achievement "${achievement.name}".`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await removeUserAchievement(user.id, achievementId);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
await interaction.editReply(
|
||||||
|
`Achievement "${achievement.name}" has been removed from ${user.username}.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (achievement.rewardType === 'role' && achievement.rewardValue) {
|
||||||
|
try {
|
||||||
|
const member = await interaction.guild!.members.fetch(user.id);
|
||||||
|
await member.roles.remove(achievement.rewardValue);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`Failed to remove role ${achievement.rewardValue} from user ${user.id}`,
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
await interaction.followUp({
|
||||||
|
content:
|
||||||
|
'Note: Failed to remove the role reward. Please check permissions and remove it manually if needed.',
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await interaction.editReply(
|
||||||
|
`Failed to remove achievement "${achievement.name}" from ${user.username}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing achievement from user:', error);
|
||||||
|
await interaction.editReply(
|
||||||
|
'An error occurred while removing the achievement.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAchievementsEmbed(
|
||||||
|
achievements: Array<any>,
|
||||||
|
title: string,
|
||||||
|
user: any,
|
||||||
|
overallProgress: number = 0,
|
||||||
|
earnedCount: number = 0,
|
||||||
|
totalAchievements: number = 0,
|
||||||
|
) {
|
||||||
|
return createPageEmbed(
|
||||||
|
achievements,
|
||||||
|
title,
|
||||||
|
user,
|
||||||
|
overallProgress,
|
||||||
|
earnedCount,
|
||||||
|
totalAchievements,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a visual progress bar
|
||||||
|
* @param progress - Number between 0-100
|
||||||
|
* @returns A string representing a progress bar
|
||||||
|
*/
|
||||||
|
function createProgressBar(progress: number): string {
|
||||||
|
const filledBars = Math.round(progress / 10);
|
||||||
|
const emptyBars = 10 - filledBars;
|
||||||
|
|
||||||
|
const filled = '█'.repeat(filledBars);
|
||||||
|
const empty = '░'.repeat(emptyBars);
|
||||||
|
|
||||||
|
return `[${filled}${empty}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatType(type: string): string {
|
||||||
|
return type.charAt(0).toUpperCase() + type.slice(1).replace('_', ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits achievements into pages for pagination
|
||||||
|
*/
|
||||||
|
function splitAchievementsIntoPages(
|
||||||
|
achievements: Array<any>,
|
||||||
|
title: string,
|
||||||
|
user: any,
|
||||||
|
overallProgress: number = 0,
|
||||||
|
earnedCount: number = 0,
|
||||||
|
totalAchievements: number = 0,
|
||||||
|
achievementsPerPage: number = 5,
|
||||||
|
): EmbedBuilder[] {
|
||||||
|
if (achievements.length === 0) {
|
||||||
|
return [
|
||||||
|
createAchievementsEmbed(
|
||||||
|
achievements,
|
||||||
|
title,
|
||||||
|
user,
|
||||||
|
overallProgress,
|
||||||
|
earnedCount,
|
||||||
|
totalAchievements,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupedAchievements: Record<string, typeof achievements> = {
|
||||||
|
message_count: achievements.filter(
|
||||||
|
(a) => a.definition?.requirementType === 'message_count',
|
||||||
|
),
|
||||||
|
level: achievements.filter(
|
||||||
|
(a) => a.definition?.requirementType === 'level',
|
||||||
|
),
|
||||||
|
command_usage: achievements.filter(
|
||||||
|
(a) => a.definition?.requirementType === 'command_usage',
|
||||||
|
),
|
||||||
|
reactions: achievements.filter(
|
||||||
|
(a) => a.definition?.requirementType === 'reactions',
|
||||||
|
),
|
||||||
|
other: achievements.filter(
|
||||||
|
(a) =>
|
||||||
|
!['message_count', 'level', 'command_usage', 'reactions'].includes(
|
||||||
|
a.definition?.requirementType,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let orderedAchievements: typeof achievements = [];
|
||||||
|
for (const [type, typeAchievements] of Object.entries(groupedAchievements)) {
|
||||||
|
if (typeAchievements.length > 0) {
|
||||||
|
orderedAchievements = orderedAchievements.concat(
|
||||||
|
typeAchievements.map((ach) => ({
|
||||||
|
...ach,
|
||||||
|
achievementType: type,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: (typeof achievements)[] = [];
|
||||||
|
for (let i = 0; i < orderedAchievements.length; i += achievementsPerPage) {
|
||||||
|
chunks.push(orderedAchievements.slice(i, i + achievementsPerPage));
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks.map((chunk, index) => {
|
||||||
|
return createPageEmbed(
|
||||||
|
chunk,
|
||||||
|
title,
|
||||||
|
user,
|
||||||
|
overallProgress,
|
||||||
|
earnedCount,
|
||||||
|
totalAchievements,
|
||||||
|
index + 1,
|
||||||
|
chunks.length,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an embed for a single page of achievements
|
||||||
|
*/
|
||||||
|
function createPageEmbed(
|
||||||
|
achievements: Array<any>,
|
||||||
|
title: string,
|
||||||
|
user: any,
|
||||||
|
overallProgress: number = 0,
|
||||||
|
earnedCount: number = 0,
|
||||||
|
totalAchievements: number = 0,
|
||||||
|
pageNumber: number = 1,
|
||||||
|
totalPages: number = 1,
|
||||||
|
): EmbedBuilder {
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setColor(0x0099ff)
|
||||||
|
.setTitle(`${user.username}'s ${title}`)
|
||||||
|
.setThumbnail(user.displayAvatarURL())
|
||||||
|
.setFooter({ text: `Page ${pageNumber}/${totalPages}` });
|
||||||
|
|
||||||
|
if (achievements.length === 0) {
|
||||||
|
embed.setDescription('No achievements found.');
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentType: string | null = null;
|
||||||
|
|
||||||
|
achievements.forEach((achievement) => {
|
||||||
|
const { definition, achievementType } = achievement;
|
||||||
|
if (!definition) return;
|
||||||
|
|
||||||
|
if (achievementType && achievementType !== currentType) {
|
||||||
|
currentType = achievementType;
|
||||||
|
embed.addFields({
|
||||||
|
name: `${formatType(currentType || '')} Achievements`,
|
||||||
|
value: '⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let fieldValue = definition.description;
|
||||||
|
|
||||||
|
if (
|
||||||
|
achievement.earnedAt &&
|
||||||
|
achievement.earnedAt !== null &&
|
||||||
|
achievement.earnedAt !== undefined &&
|
||||||
|
new Date(achievement.earnedAt).getTime() > 0
|
||||||
|
) {
|
||||||
|
const earnedDate = new Date(achievement.earnedAt);
|
||||||
|
fieldValue += `\n✅ **Completed**: <t:${Math.floor(earnedDate.getTime() / 1000)}:R>`;
|
||||||
|
} else {
|
||||||
|
const progress = achievement.progress || 0;
|
||||||
|
const progressBar = createProgressBar(progress);
|
||||||
|
fieldValue += `\n${progressBar} **${progress}%**`;
|
||||||
|
|
||||||
|
if (definition.requirementType === 'message_count') {
|
||||||
|
fieldValue += `\n📨 Send ${definition.threshold} messages`;
|
||||||
|
} else if (definition.requirementType === 'level') {
|
||||||
|
fieldValue += `\n🏆 Reach level ${definition.threshold}`;
|
||||||
|
} else if (definition.requirementType === 'command_usage') {
|
||||||
|
const cmdName = definition.requirement?.command || 'unknown';
|
||||||
|
fieldValue += `\n🔧 Use /${cmdName} command`;
|
||||||
|
} else if (definition.requirementType === 'reactions') {
|
||||||
|
fieldValue += `\n😀 Add ${definition.threshold} reactions`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (definition.rewardType && definition.rewardValue) {
|
||||||
|
fieldValue += `\n💰 **Reward**: ${
|
||||||
|
definition.rewardType === 'xp'
|
||||||
|
? `${definition.rewardValue} XP`
|
||||||
|
: `Role <@&${definition.rewardValue}>`
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
embed.addFields({
|
||||||
|
name: definition.name,
|
||||||
|
value: fieldValue,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
embed.addFields({
|
||||||
|
name: '📊 Overall Achievement Progress',
|
||||||
|
value:
|
||||||
|
`${createProgressBar(overallProgress)} **${overallProgress}%**\n` +
|
||||||
|
`You've earned **${earnedCount}** of ${totalAchievements} achievements`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default command;
|
125
src/commands/fun/counting.ts
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
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() || !interaction.guild) return;
|
||||||
|
|
||||||
|
await interaction.deferReply();
|
||||||
|
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.editReply({ embeds: [embed] });
|
||||||
|
} else if (subcommand === 'setcount') {
|
||||||
|
if (
|
||||||
|
!interaction.memberPermissions?.has(
|
||||||
|
PermissionsBitField.Flags.Administrator,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: 'You need administrator permissions to use this command.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = interaction.options.getInteger('count');
|
||||||
|
if (count === null) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: 'Invalid count specified.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await setCount(count);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Count has been set to **${count}**. The next number should be **${count + 1}**.`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Failed to set the count: ${error}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Count has been set to **${count}**. The next number should be **${count + 1}**.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default command;
|
311
src/commands/fun/fact.ts
Normal file
|
@ -0,0 +1,311 @@
|
||||||
|
import {
|
||||||
|
SlashCommandBuilder,
|
||||||
|
PermissionsBitField,
|
||||||
|
EmbedBuilder,
|
||||||
|
ActionRowBuilder,
|
||||||
|
ButtonBuilder,
|
||||||
|
ButtonStyle,
|
||||||
|
} from 'discord.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
addFact,
|
||||||
|
getPendingFacts,
|
||||||
|
approveFact,
|
||||||
|
deleteFact,
|
||||||
|
getLastInsertedFactId,
|
||||||
|
} from '@/db/db.js';
|
||||||
|
import { postFactOfTheDay } from '@/util/factManager.js';
|
||||||
|
import { loadConfig } from '@/util/configLoader.js';
|
||||||
|
import { SubcommandCommand } from '@/types/CommandTypes.js';
|
||||||
|
import { createPaginationButtons } from '@/util/helpers.js';
|
||||||
|
|
||||||
|
const command: SubcommandCommand = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('fact')
|
||||||
|
.setDescription('Manage facts of the day')
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('submit')
|
||||||
|
.setDescription('Submit a new fact for approval')
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('content')
|
||||||
|
.setDescription('The fact content')
|
||||||
|
.setRequired(true),
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('source')
|
||||||
|
.setDescription('Source of the fact (optional)')
|
||||||
|
.setRequired(false),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('approve')
|
||||||
|
.setDescription('Approve a pending fact (Mod only)')
|
||||||
|
.addIntegerOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('id')
|
||||||
|
.setDescription('The ID of the fact to approve')
|
||||||
|
.setRequired(true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('delete')
|
||||||
|
.setDescription('Delete a fact (Mod only)')
|
||||||
|
.addIntegerOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('id')
|
||||||
|
.setDescription('The ID of the fact to delete')
|
||||||
|
.setRequired(true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('pending')
|
||||||
|
.setDescription('List all pending facts (Mod only)'),
|
||||||
|
)
|
||||||
|
.addSubcommand((subcommand) =>
|
||||||
|
subcommand
|
||||||
|
.setName('post')
|
||||||
|
.setDescription('Post a fact of the day manually (Admin only)'),
|
||||||
|
),
|
||||||
|
|
||||||
|
execute: async (interaction) => {
|
||||||
|
if (!interaction.isChatInputCommand() || !interaction.guild) return;
|
||||||
|
|
||||||
|
await interaction.deferReply({
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
|
||||||
|
if (subcommand === 'submit') {
|
||||||
|
const content = interaction.options.getString('content', true);
|
||||||
|
const source = interaction.options.getString('source') || undefined;
|
||||||
|
|
||||||
|
const isAdmin = interaction.memberPermissions?.has(
|
||||||
|
PermissionsBitField.Flags.Administrator,
|
||||||
|
);
|
||||||
|
|
||||||
|
await addFact({
|
||||||
|
content,
|
||||||
|
source,
|
||||||
|
addedBy: interaction.user.id,
|
||||||
|
approved: isAdmin ? true : false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
const approvalChannel = interaction.guild.channels.cache.get(
|
||||||
|
config.channels.factApproval,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (approvalChannel?.isTextBased()) {
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle('New Fact Submission')
|
||||||
|
.setDescription(content)
|
||||||
|
.setColor(0x0099ff)
|
||||||
|
.addFields(
|
||||||
|
{
|
||||||
|
name: 'Submitted By',
|
||||||
|
value: `<@${interaction.user.id}>`,
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
{ name: 'Source', value: source || 'Not provided', inline: true },
|
||||||
|
)
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
const factId = await getLastInsertedFactId();
|
||||||
|
|
||||||
|
const approveButton = new ButtonBuilder()
|
||||||
|
.setCustomId(`approve_fact_${factId}`)
|
||||||
|
.setLabel('Approve')
|
||||||
|
.setStyle(ButtonStyle.Success);
|
||||||
|
|
||||||
|
const rejectButton = new ButtonBuilder()
|
||||||
|
.setCustomId(`reject_fact_${factId}`)
|
||||||
|
.setLabel('Reject')
|
||||||
|
.setStyle(ButtonStyle.Danger);
|
||||||
|
|
||||||
|
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
|
approveButton,
|
||||||
|
rejectButton,
|
||||||
|
);
|
||||||
|
|
||||||
|
await approvalChannel.send({
|
||||||
|
embeds: [embed],
|
||||||
|
components: [row],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.error('Approval channel not found or is not a text channel');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
content: isAdmin
|
||||||
|
? 'Your fact has been automatically approved and added to the database!'
|
||||||
|
: 'Your fact has been submitted for approval!',
|
||||||
|
});
|
||||||
|
} else if (subcommand === 'approve') {
|
||||||
|
if (
|
||||||
|
!interaction.memberPermissions?.has(
|
||||||
|
PermissionsBitField.Flags.ModerateMembers,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: 'You do not have permission to approve facts.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = interaction.options.getInteger('id', true);
|
||||||
|
await approveFact(id);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Fact #${id} has been approved!`,
|
||||||
|
});
|
||||||
|
} else if (subcommand === 'delete') {
|
||||||
|
if (
|
||||||
|
!interaction.memberPermissions?.has(
|
||||||
|
PermissionsBitField.Flags.ModerateMembers,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: 'You do not have permission to delete facts.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = interaction.options.getInteger('id', true);
|
||||||
|
await deleteFact(id);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `Fact #${id} has been deleted!`,
|
||||||
|
});
|
||||||
|
} else if (subcommand === 'pending') {
|
||||||
|
if (
|
||||||
|
!interaction.memberPermissions?.has(
|
||||||
|
PermissionsBitField.Flags.ModerateMembers,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: 'You do not have permission to view pending facts.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FACTS_PER_PAGE = 5;
|
||||||
|
const pendingFacts = await getPendingFacts();
|
||||||
|
|
||||||
|
if (pendingFacts.length === 0) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: 'There are no pending facts.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages: EmbedBuilder[] = [];
|
||||||
|
for (let i = 0; i < pendingFacts.length; i += FACTS_PER_PAGE) {
|
||||||
|
const pageFacts = pendingFacts.slice(i, i + FACTS_PER_PAGE);
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle('Pending Facts')
|
||||||
|
.setColor(0x0099ff)
|
||||||
|
.setDescription(
|
||||||
|
pageFacts
|
||||||
|
.map((fact) => {
|
||||||
|
return `**ID #${fact.id}**\n${fact.content}\nSubmitted by: <@${fact.addedBy}>\nSource: ${fact.source || 'Not provided'}`;
|
||||||
|
})
|
||||||
|
.join('\n\n'),
|
||||||
|
)
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
pages.push(embed);
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentPage = 0;
|
||||||
|
|
||||||
|
const message = await interaction.editReply({
|
||||||
|
embeds: [pages[currentPage]],
|
||||||
|
components: [createPaginationButtons(pages.length, currentPage)],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pages.length <= 1) return;
|
||||||
|
|
||||||
|
const collector = message.createMessageComponentCollector({
|
||||||
|
time: 300000,
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on('collect', async (i) => {
|
||||||
|
if (i.user.id !== interaction.user.id) {
|
||||||
|
await i.reply({
|
||||||
|
content: 'These controls are not for you!',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i.isButton()) {
|
||||||
|
switch (i.customId) {
|
||||||
|
case 'first':
|
||||||
|
currentPage = 0;
|
||||||
|
break;
|
||||||
|
case 'prev':
|
||||||
|
if (currentPage > 0) currentPage--;
|
||||||
|
break;
|
||||||
|
case 'next':
|
||||||
|
if (currentPage < pages.length - 1) currentPage++;
|
||||||
|
break;
|
||||||
|
case 'last':
|
||||||
|
currentPage = pages.length - 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i.isStringSelectMenu()) {
|
||||||
|
const selected = parseInt(i.values[0]);
|
||||||
|
if (!isNaN(selected) && selected >= 0 && selected < pages.length) {
|
||||||
|
currentPage = selected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await i.update({
|
||||||
|
embeds: [pages[currentPage]],
|
||||||
|
components: [createPaginationButtons(pages.length, currentPage)],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on('end', async () => {
|
||||||
|
if (message) {
|
||||||
|
try {
|
||||||
|
await interaction.editReply({ components: [] });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing components:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (subcommand === 'post') {
|
||||||
|
if (
|
||||||
|
!interaction.memberPermissions?.has(
|
||||||
|
PermissionsBitField.Flags.Administrator,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: 'You do not have permission to manually post facts.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await postFactOfTheDay(interaction.client);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
content: 'Fact of the day has been posted!',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default command;
|
376
src/commands/fun/giveaway.ts
Normal file
|
@ -0,0 +1,376 @@
|
||||||
|
import {
|
||||||
|
SlashCommandBuilder,
|
||||||
|
PermissionsBitField,
|
||||||
|
EmbedBuilder,
|
||||||
|
ChatInputCommandInteraction,
|
||||||
|
} from 'discord.js';
|
||||||
|
|
||||||
|
import { SubcommandCommand } from '@/types/CommandTypes.js';
|
||||||
|
import {
|
||||||
|
getGiveaway,
|
||||||
|
getActiveGiveaways,
|
||||||
|
endGiveaway,
|
||||||
|
rerollGiveaway,
|
||||||
|
} from '@/db/db.js';
|
||||||
|
import {
|
||||||
|
createGiveawayEmbed,
|
||||||
|
formatWinnerMentions,
|
||||||
|
builder,
|
||||||
|
} from '@/util/giveaways/giveawayManager.js';
|
||||||
|
import { createPaginationButtons } from '@/util/helpers.js';
|
||||||
|
import { loadConfig } from '@/util/configLoader.js';
|
||||||
|
|
||||||
|
const command: SubcommandCommand = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('giveaway')
|
||||||
|
.setDescription('Create and manage giveaways')
|
||||||
|
.addSubcommand((sub) =>
|
||||||
|
sub.setName('create').setDescription('Start creating a new giveaway'),
|
||||||
|
)
|
||||||
|
.addSubcommand((sub) =>
|
||||||
|
sub.setName('list').setDescription('List all active giveaways'),
|
||||||
|
)
|
||||||
|
.addSubcommand((sub) =>
|
||||||
|
sub
|
||||||
|
.setName('end')
|
||||||
|
.setDescription('End a giveaway early')
|
||||||
|
.addStringOption((opt) =>
|
||||||
|
opt
|
||||||
|
.setName('id')
|
||||||
|
.setDescription('Id of the giveaway')
|
||||||
|
.setRequired(true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.addSubcommand((sub) =>
|
||||||
|
sub
|
||||||
|
.setName('reroll')
|
||||||
|
.setDescription('Reroll winners for a giveaway')
|
||||||
|
.addStringOption((opt) =>
|
||||||
|
opt
|
||||||
|
.setName('id')
|
||||||
|
.setDescription('Id of the giveaway')
|
||||||
|
.setRequired(true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
execute: async (interaction) => {
|
||||||
|
if (!interaction.isChatInputCommand() || !interaction.guild) return;
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
const communityManagerRoleId = config.roles.staffRoles.find(
|
||||||
|
(role) => role.name === 'Community Manager',
|
||||||
|
)?.roleId;
|
||||||
|
|
||||||
|
if (!communityManagerRoleId) {
|
||||||
|
await interaction.reply({
|
||||||
|
content:
|
||||||
|
'Community Manager role not found in the configuration. Please contact a server admin.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!interaction.guild.members.cache
|
||||||
|
.find((member) => member.id === interaction.user.id)
|
||||||
|
?.roles.cache.has(communityManagerRoleId)
|
||||||
|
) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: 'You do not have permission to manage giveaways.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
|
||||||
|
switch (subcommand) {
|
||||||
|
case 'create':
|
||||||
|
await handleCreateGiveaway(interaction);
|
||||||
|
break;
|
||||||
|
case 'list':
|
||||||
|
await handleListGiveaways(interaction);
|
||||||
|
break;
|
||||||
|
case 'end':
|
||||||
|
await handleEndGiveaway(interaction);
|
||||||
|
break;
|
||||||
|
case 'reroll':
|
||||||
|
await handleRerollGiveaway(interaction);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the giveaway creation process
|
||||||
|
*/
|
||||||
|
async function handleCreateGiveaway(interaction: ChatInputCommandInteraction) {
|
||||||
|
await builder.startGiveawayBuilder(interaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the list giveaways subcommand
|
||||||
|
*/
|
||||||
|
async function handleListGiveaways(interaction: ChatInputCommandInteraction) {
|
||||||
|
await interaction.deferReply();
|
||||||
|
const GIVEAWAYS_PER_PAGE = 5;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const activeGiveaways = await getActiveGiveaways();
|
||||||
|
|
||||||
|
if (activeGiveaways.length === 0) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: 'There are no active giveaways at the moment.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages: EmbedBuilder[] = [];
|
||||||
|
for (let i = 0; i < activeGiveaways.length; i += GIVEAWAYS_PER_PAGE) {
|
||||||
|
const pageGiveaways = activeGiveaways.slice(i, i + GIVEAWAYS_PER_PAGE);
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle('🎉 Active Giveaways')
|
||||||
|
.setColor(0x00ff00)
|
||||||
|
.setDescription('Here are the currently active giveaways:')
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
pageGiveaways.forEach((giveaway) => {
|
||||||
|
embed.addFields({
|
||||||
|
name: `${giveaway.prize} (ID: ${giveaway.id})`,
|
||||||
|
value: [
|
||||||
|
`**Hosted by:** <@${giveaway.hostId}>`,
|
||||||
|
`**Winners:** ${giveaway.winnerCount}`,
|
||||||
|
`**Ends:** <t:${Math.floor(giveaway.endAt.getTime() / 1000)}:R>`,
|
||||||
|
`**Entries:** ${giveaway.participants?.length || 0}`,
|
||||||
|
`[Jump to Giveaway](https://discord.com/channels/${interaction.guildId}/${giveaway.channelId}/${giveaway.messageId})`,
|
||||||
|
].join('\n'),
|
||||||
|
inline: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
pages.push(embed);
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentPage = 0;
|
||||||
|
|
||||||
|
const message = await interaction.editReply({
|
||||||
|
embeds: [pages[currentPage]],
|
||||||
|
components: [createPaginationButtons(pages.length, currentPage)],
|
||||||
|
});
|
||||||
|
|
||||||
|
const collector = message.createMessageComponentCollector({
|
||||||
|
time: 300000,
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on('collect', async (i) => {
|
||||||
|
if (i.user.id !== interaction.user.id) {
|
||||||
|
await i.reply({
|
||||||
|
content: 'You cannot use these buttons.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i.isButton()) {
|
||||||
|
switch (i.customId) {
|
||||||
|
case 'first':
|
||||||
|
currentPage = 0;
|
||||||
|
break;
|
||||||
|
case 'prev':
|
||||||
|
if (currentPage > 0) currentPage--;
|
||||||
|
break;
|
||||||
|
case 'next':
|
||||||
|
if (currentPage < pages.length - 1) currentPage++;
|
||||||
|
break;
|
||||||
|
case 'last':
|
||||||
|
currentPage = pages.length - 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await i.update({
|
||||||
|
embeds: [pages[currentPage]],
|
||||||
|
components: [createPaginationButtons(pages.length, currentPage)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on('end', async () => {
|
||||||
|
try {
|
||||||
|
await interaction.editReply({
|
||||||
|
components: [],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing components:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching giveaways:', error);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: 'There was an error fetching the giveaways.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the end giveaway subcommand
|
||||||
|
*/
|
||||||
|
async function handleEndGiveaway(interaction: ChatInputCommandInteraction) {
|
||||||
|
await interaction.deferReply();
|
||||||
|
|
||||||
|
const id = interaction.options.getString('id', true);
|
||||||
|
const giveaway = await getGiveaway(id, true);
|
||||||
|
|
||||||
|
if (!giveaway) {
|
||||||
|
await interaction.editReply(`Giveaway with ID ${id} not found.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (giveaway.status !== 'active') {
|
||||||
|
await interaction.editReply('This giveaway has already ended.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endedGiveaway = await endGiveaway(id, true);
|
||||||
|
if (!endedGiveaway) {
|
||||||
|
await interaction.editReply(
|
||||||
|
'Failed to end the giveaway. Please try again.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const channel = interaction.guild?.channels.cache.get(giveaway.channelId);
|
||||||
|
if (!channel?.isTextBased()) {
|
||||||
|
await interaction.editReply(
|
||||||
|
'Giveaway channel not found or is not a text channel.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageId = giveaway.messageId;
|
||||||
|
const giveawayMessage = await channel.messages.fetch(messageId);
|
||||||
|
|
||||||
|
if (!giveawayMessage) {
|
||||||
|
await interaction.editReply('Giveaway message not found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await giveawayMessage.edit({
|
||||||
|
embeds: [
|
||||||
|
createGiveawayEmbed({
|
||||||
|
id: endedGiveaway.id,
|
||||||
|
prize: endedGiveaway.prize,
|
||||||
|
hostId: endedGiveaway.hostId,
|
||||||
|
winnersIds: endedGiveaway.winnersIds ?? [],
|
||||||
|
isEnded: true,
|
||||||
|
footerText: 'Ended early by a moderator',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
components: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (endedGiveaway.winnersIds?.length) {
|
||||||
|
const winnerMentions = formatWinnerMentions(endedGiveaway.winnersIds);
|
||||||
|
await channel.send({
|
||||||
|
content: `Congratulations ${winnerMentions}! You won **${endedGiveaway.prize}**!`,
|
||||||
|
allowedMentions: { users: endedGiveaway.winnersIds },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await channel.send(
|
||||||
|
`No one entered the giveaway for **${endedGiveaway.prize}**!`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.editReply('Giveaway ended successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error ending giveaway:', error);
|
||||||
|
await interaction.editReply('Failed to update the giveaway message.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the reroll giveaway subcommand
|
||||||
|
*/
|
||||||
|
async function handleRerollGiveaway(interaction: ChatInputCommandInteraction) {
|
||||||
|
await interaction.deferReply({ flags: ['Ephemeral'] });
|
||||||
|
const id = interaction.options.getString('id', true);
|
||||||
|
|
||||||
|
const originalGiveaway = await getGiveaway(id, true);
|
||||||
|
|
||||||
|
if (!originalGiveaway) {
|
||||||
|
await interaction.editReply(`Giveaway with ID ${id} not found.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originalGiveaway.status !== 'ended') {
|
||||||
|
await interaction.editReply(
|
||||||
|
'This giveaway is not yet ended. You can only reroll ended giveaways.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!originalGiveaway.participants?.length) {
|
||||||
|
await interaction.editReply(
|
||||||
|
'Cannot reroll because no one entered this giveaway.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rerolledGiveaway = await rerollGiveaway(id);
|
||||||
|
|
||||||
|
if (!rerolledGiveaway) {
|
||||||
|
await interaction.editReply(
|
||||||
|
'Failed to reroll the giveaway. An internal error occurred.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousWinners = originalGiveaway.winnersIds ?? [];
|
||||||
|
const newWinners = rerolledGiveaway.winnersIds ?? [];
|
||||||
|
|
||||||
|
const winnersChanged = !(
|
||||||
|
previousWinners.length === newWinners.length &&
|
||||||
|
previousWinners.every((w) => newWinners.includes(w))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!winnersChanged && newWinners.length > 0) {
|
||||||
|
await interaction.editReply(
|
||||||
|
'Could not reroll: No other eligible participants found besides the previous winner(s).',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newWinners.length === 0) {
|
||||||
|
await interaction.editReply(
|
||||||
|
'Could not reroll: No eligible participants found.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const channel = interaction.guild?.channels.cache.get(
|
||||||
|
rerolledGiveaway.channelId,
|
||||||
|
);
|
||||||
|
if (!channel?.isTextBased()) {
|
||||||
|
await interaction.editReply(
|
||||||
|
'Giveaway channel not found or is not a text channel. Reroll successful but announcement failed.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const winnerMentions = formatWinnerMentions(newWinners);
|
||||||
|
await channel.send({
|
||||||
|
content: `🎉 The giveaway for **${rerolledGiveaway.prize}** has been rerolled! New winner(s): ${winnerMentions}`,
|
||||||
|
allowedMentions: { users: newWinners },
|
||||||
|
});
|
||||||
|
|
||||||
|
await interaction.editReply('Giveaway rerolled successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error announcing rerolled giveaway:', error);
|
||||||
|
await interaction.editReply(
|
||||||
|
'Giveaway rerolled, but failed to announce the new winners.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default command;
|
167
src/commands/fun/leaderboard.ts
Normal file
|
@ -0,0 +1,167 @@
|
||||||
|
import {
|
||||||
|
SlashCommandBuilder,
|
||||||
|
EmbedBuilder,
|
||||||
|
ActionRowBuilder,
|
||||||
|
StringSelectMenuBuilder,
|
||||||
|
APIEmbed,
|
||||||
|
JSONEncodable,
|
||||||
|
} from 'discord.js';
|
||||||
|
|
||||||
|
import { OptionsCommand } from '@/types/CommandTypes.js';
|
||||||
|
import { getLevelLeaderboard } from '@/db/db.js';
|
||||||
|
import { createPaginationButtons } from '@/util/helpers.js';
|
||||||
|
|
||||||
|
const command: OptionsCommand = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('leaderboard')
|
||||||
|
.setDescription('Shows the server XP leaderboard')
|
||||||
|
.addIntegerOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('limit')
|
||||||
|
.setDescription('Number of users per page (default: 10)')
|
||||||
|
.setRequired(false),
|
||||||
|
),
|
||||||
|
execute: async (interaction) => {
|
||||||
|
if (!interaction.isChatInputCommand() || !interaction.guild) return;
|
||||||
|
|
||||||
|
await interaction.deferReply();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const usersPerPage = interaction.options.getInteger('limit') || 10;
|
||||||
|
|
||||||
|
const allUsers = await getLevelLeaderboard(100);
|
||||||
|
|
||||||
|
if (allUsers.length === 0) {
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle('🏆 Server Leaderboard')
|
||||||
|
.setColor(0x5865f2)
|
||||||
|
.setDescription('No users found on the leaderboard yet.')
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
await interaction.editReply({ embeds: [embed] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages: (APIEmbed | JSONEncodable<APIEmbed>)[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < allUsers.length; i += usersPerPage) {
|
||||||
|
const pageUsers = allUsers.slice(i, i + usersPerPage);
|
||||||
|
let leaderboardText = '';
|
||||||
|
|
||||||
|
for (let j = 0; j < pageUsers.length; j++) {
|
||||||
|
const user = pageUsers[j];
|
||||||
|
const position = i + j + 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const member = await interaction.guild.members.fetch(
|
||||||
|
user.discordId,
|
||||||
|
);
|
||||||
|
leaderboardText += `**${position}.** ${member} - Level ${user.level} (${user.xp} XP)\n`;
|
||||||
|
} catch (error) {
|
||||||
|
leaderboardText += `**${position}.** <@${user.discordId}> - Level ${user.level} (${user.xp} XP)\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle('🏆 Server Leaderboard')
|
||||||
|
.setColor(0x5865f2)
|
||||||
|
.setDescription(leaderboardText)
|
||||||
|
.setTimestamp()
|
||||||
|
.setFooter({
|
||||||
|
text: `Page ${Math.floor(i / usersPerPage) + 1} of ${Math.ceil(allUsers.length / usersPerPage)}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
pages.push(embed);
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentPage = 0;
|
||||||
|
|
||||||
|
const getButtonActionRow = () =>
|
||||||
|
createPaginationButtons(pages.length, currentPage);
|
||||||
|
|
||||||
|
const getSelectMenuRow = () => {
|
||||||
|
const options = pages.map((_, index) => ({
|
||||||
|
label: `Page ${index + 1}`,
|
||||||
|
value: index.toString(),
|
||||||
|
default: index === currentPage,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const select = new StringSelectMenuBuilder()
|
||||||
|
.setCustomId('select_page')
|
||||||
|
.setPlaceholder('Jump to a page')
|
||||||
|
.addOptions(options);
|
||||||
|
|
||||||
|
return new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
||||||
|
select,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const components =
|
||||||
|
pages.length > 1 ? [getButtonActionRow(), getSelectMenuRow()] : [];
|
||||||
|
|
||||||
|
const message = await interaction.editReply({
|
||||||
|
embeds: [pages[currentPage]],
|
||||||
|
components,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pages.length <= 1) return;
|
||||||
|
|
||||||
|
const collector = message.createMessageComponentCollector({
|
||||||
|
time: 300000,
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on('collect', async (i) => {
|
||||||
|
if (i.user.id !== interaction.user.id) {
|
||||||
|
await i.reply({
|
||||||
|
content: 'These controls are not for you!',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i.isButton()) {
|
||||||
|
switch (i.customId) {
|
||||||
|
case 'first':
|
||||||
|
currentPage = 0;
|
||||||
|
break;
|
||||||
|
case 'prev':
|
||||||
|
if (currentPage > 0) currentPage--;
|
||||||
|
break;
|
||||||
|
case 'next':
|
||||||
|
if (currentPage < pages.length - 1) currentPage++;
|
||||||
|
break;
|
||||||
|
case 'last':
|
||||||
|
currentPage = pages.length - 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i.isStringSelectMenu()) {
|
||||||
|
const selected = parseInt(i.values[0]);
|
||||||
|
if (!isNaN(selected) && selected >= 0 && selected < pages.length) {
|
||||||
|
currentPage = selected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await i.update({
|
||||||
|
embeds: [pages[currentPage]],
|
||||||
|
components: [getButtonActionRow(), getSelectMenuRow()],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on('end', async () => {
|
||||||
|
if (message) {
|
||||||
|
try {
|
||||||
|
await interaction.editReply({ components: [] });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing components:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting leaderboard:', error);
|
||||||
|
await interaction.editReply('Failed to get leaderboard information.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default command;
|
44
src/commands/fun/rank.ts
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
import { SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
|
import { OptionsCommand } from '@/types/CommandTypes.js';
|
||||||
|
import { generateRankCard, getXpToNextLevel } from '@/util/levelingSystem.js';
|
||||||
|
import { getUserLevel } from '@/db/db.js';
|
||||||
|
|
||||||
|
const command: OptionsCommand = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('rank')
|
||||||
|
.setDescription('Shows your current rank and level')
|
||||||
|
.addUserOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('user')
|
||||||
|
.setDescription('The user to check rank for (defaults to yourself)')
|
||||||
|
.setRequired(false),
|
||||||
|
),
|
||||||
|
execute: async (interaction) => {
|
||||||
|
if (!interaction.isChatInputCommand() || !interaction.guild) return;
|
||||||
|
|
||||||
|
await interaction.deferReply();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const member = await interaction.guild.members.fetch(
|
||||||
|
(interaction.options.get('user')?.value as string) ||
|
||||||
|
interaction.user.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
const userData = await getUserLevel(member.id);
|
||||||
|
const rankCard = await generateRankCard(member, userData);
|
||||||
|
|
||||||
|
const xpToNextLevel = getXpToNextLevel(userData.level, userData.xp);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `${member}'s rank - Level ${userData.level} (${userData.xp} XP, ${xpToNextLevel} XP until next level)`,
|
||||||
|
files: [rankCard],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting rank:', error);
|
||||||
|
await interaction.editReply('Failed to get rank information.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default command;
|
|
@ -1,9 +1,10 @@
|
||||||
import { PermissionsBitField, SlashCommandBuilder } from 'discord.js';
|
import { PermissionsBitField, SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
import { updateMember, updateMemberModerationHistory } from '../../db/db.js';
|
import { updateMember, updateMemberModerationHistory } from '@/db/db.js';
|
||||||
import { parseDuration, scheduleUnban } from '../../util/helpers.js';
|
import { parseDuration, scheduleUnban } from '@/util/helpers.js';
|
||||||
import { OptionsCommand } from '../../types/CommandTypes.js';
|
import { OptionsCommand } from '@/types/CommandTypes.js';
|
||||||
import logAction from '../../util/logging/logAction.js';
|
import { loadConfig } from '@/util/configLoader.js';
|
||||||
|
import logAction from '@/util/logging/logAction.js';
|
||||||
|
|
||||||
const command: OptionsCommand = {
|
const command: OptionsCommand = {
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
|
@ -30,40 +31,63 @@ const command: OptionsCommand = {
|
||||||
.setRequired(false),
|
.setRequired(false),
|
||||||
),
|
),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
const moderator = await interaction.guild?.members.fetch(
|
if (!interaction.isChatInputCommand() || !interaction.guild) return;
|
||||||
interaction.user.id,
|
|
||||||
);
|
|
||||||
const member = await interaction.guild?.members.fetch(
|
|
||||||
interaction.options.get('member')!.value as string,
|
|
||||||
);
|
|
||||||
const reason = interaction.options.get('reason')?.value as string;
|
|
||||||
const banDuration = interaction.options.get('duration')?.value as
|
|
||||||
| string
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
if (
|
await interaction.deferReply({ flags: ['Ephemeral'] });
|
||||||
!interaction.memberPermissions?.has(
|
|
||||||
PermissionsBitField.Flags.BanMembers,
|
|
||||||
) ||
|
|
||||||
moderator!.roles.highest.position <= member!.roles.highest.position ||
|
|
||||||
!member?.bannable
|
|
||||||
) {
|
|
||||||
await interaction.reply({
|
|
||||||
content:
|
|
||||||
'You do not have permission to ban members or this member cannot be banned.',
|
|
||||||
flags: ['Ephemeral'],
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await member.user.send(
|
const moderator = await interaction.guild.members.fetch(
|
||||||
banDuration
|
interaction.user.id,
|
||||||
? `You have been banned from ${interaction.guild!.name} for ${banDuration}. Reason: ${reason}. You can join back at ${new Date(
|
|
||||||
Date.now() + parseDuration(banDuration),
|
|
||||||
).toUTCString()} using the link below:\nhttps://discord.gg/KRTGjxx7gY`
|
|
||||||
: `You been indefinitely banned from ${interaction.guild!.name}. Reason: ${reason}.`,
|
|
||||||
);
|
);
|
||||||
|
const member = await interaction.guild.members.fetch(
|
||||||
|
interaction.options.get('member')!.value as string,
|
||||||
|
);
|
||||||
|
const reason = interaction.options.get('reason')?.value as string;
|
||||||
|
const banDuration = interaction.options.get('duration')?.value as
|
||||||
|
| string
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!interaction.memberPermissions?.has(
|
||||||
|
PermissionsBitField.Flags.BanMembers,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: 'You do not have permission to ban members.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moderator.roles.highest.position <= member.roles.highest.position) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content:
|
||||||
|
'You cannot ban a member with equal or higher role than yours.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!member.bannable) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: 'I do not have permission to ban this member.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
const invite = interaction.guild.vanityURLCode ?? config.serverInvite;
|
||||||
|
const until = banDuration
|
||||||
|
? new Date(Date.now() + parseDuration(banDuration)).toUTCString()
|
||||||
|
: 'indefinitely';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await member.user.send(
|
||||||
|
banDuration
|
||||||
|
? `You have been banned from ${interaction.guild.name} for ${banDuration}. Reason: ${reason}. You can join back at ${until} using the link below:\n${invite}`
|
||||||
|
: `You been indefinitely banned from ${interaction.guild.name}. Reason: ${reason}.`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send DM:', error);
|
||||||
|
}
|
||||||
await member.ban({ reason });
|
await member.ban({ reason });
|
||||||
|
|
||||||
if (banDuration) {
|
if (banDuration) {
|
||||||
|
@ -72,7 +96,7 @@ const command: OptionsCommand = {
|
||||||
|
|
||||||
await scheduleUnban(
|
await scheduleUnban(
|
||||||
interaction.client,
|
interaction.client,
|
||||||
interaction.guild!.id,
|
interaction.guild.id,
|
||||||
member.id,
|
member.id,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
);
|
);
|
||||||
|
@ -94,23 +118,22 @@ const command: OptionsCommand = {
|
||||||
});
|
});
|
||||||
|
|
||||||
await logAction({
|
await logAction({
|
||||||
guild: interaction.guild!,
|
guild: interaction.guild,
|
||||||
action: 'ban',
|
action: 'ban',
|
||||||
target: member,
|
target: member,
|
||||||
moderator: moderator!,
|
moderator,
|
||||||
reason,
|
reason,
|
||||||
});
|
});
|
||||||
|
|
||||||
await interaction.reply({
|
await interaction.editReply({
|
||||||
content: banDuration
|
content: banDuration
|
||||||
? `<@${member.id}> has been banned for ${banDuration}. Reason: ${reason}`
|
? `<@${member.id}> has been banned for ${banDuration}. Reason: ${reason}`
|
||||||
: `<@${member.id}> has been indefinitely banned. Reason: ${reason}`,
|
: `<@${member.id}> has been indefinitely banned. Reason: ${reason}`,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ban command error:', error);
|
console.error('Ban command error:', error);
|
||||||
await interaction.reply({
|
await interaction.editReply({
|
||||||
content: 'Unable to ban member.',
|
content: 'Unable to ban member.',
|
||||||
flags: ['Ephemeral'],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
103
src/commands/moderation/kick.ts
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
import { PermissionsBitField, SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
|
import { updateMemberModerationHistory } from '@/db/db.js';
|
||||||
|
import { OptionsCommand } from '@/types/CommandTypes.js';
|
||||||
|
import { loadConfig } from '@/util/configLoader.js';
|
||||||
|
import logAction from '@/util/logging/logAction.js';
|
||||||
|
|
||||||
|
const command: OptionsCommand = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('kick')
|
||||||
|
.setDescription('Kick a member from the server')
|
||||||
|
.addUserOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('member')
|
||||||
|
.setDescription('The member to kick')
|
||||||
|
.setRequired(true),
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('reason')
|
||||||
|
.setDescription('The reason for the kick')
|
||||||
|
.setRequired(true),
|
||||||
|
),
|
||||||
|
execute: async (interaction) => {
|
||||||
|
if (!interaction.isChatInputCommand() || !interaction.guild) return;
|
||||||
|
|
||||||
|
await interaction.deferReply({ flags: ['Ephemeral'] });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const moderator = await interaction.guild.members.fetch(
|
||||||
|
interaction.user.id,
|
||||||
|
);
|
||||||
|
const member = await interaction.guild.members.fetch(
|
||||||
|
interaction.options.get('member')!.value as string,
|
||||||
|
);
|
||||||
|
const reason = interaction.options.get('reason')?.value as string;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!interaction.memberPermissions?.has(
|
||||||
|
PermissionsBitField.Flags.KickMembers,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: 'You do not have permission to kick members.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moderator!.roles.highest.position <= member.roles.highest.position) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content:
|
||||||
|
'You cannot kick a member with equal or higher role than yours.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!member.kickable) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: 'I do not have permission to kick this member.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await member.user.send(
|
||||||
|
`You have been kicked from ${interaction.guild!.name}. Reason: ${reason}. You can join back at: \n${interaction.guild.vanityURLCode ?? loadConfig().serverInvite}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send DM to kicked user:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
await member.kick(reason);
|
||||||
|
|
||||||
|
await updateMemberModerationHistory({
|
||||||
|
discordId: member.id,
|
||||||
|
moderatorDiscordId: interaction.user.id,
|
||||||
|
action: 'kick',
|
||||||
|
reason,
|
||||||
|
duration: '',
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await logAction({
|
||||||
|
guild: interaction.guild!,
|
||||||
|
action: 'kick',
|
||||||
|
target: member,
|
||||||
|
moderator,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `<@${member.id}> has been kicked. Reason: ${reason}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Kick command error:', error);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: 'Unable to kick member.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default command;
|
128
src/commands/moderation/mute.ts
Normal file
|
@ -0,0 +1,128 @@
|
||||||
|
import { PermissionsBitField, SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
|
import { updateMember, updateMemberModerationHistory } from '@/db/db.js';
|
||||||
|
import { parseDuration } from '@/util/helpers.js';
|
||||||
|
import { OptionsCommand } from '@/types/CommandTypes.js';
|
||||||
|
import logAction from '@/util/logging/logAction.js';
|
||||||
|
|
||||||
|
const command: OptionsCommand = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('mute')
|
||||||
|
.setDescription('Timeout a member in the server')
|
||||||
|
.addUserOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('member')
|
||||||
|
.setDescription('The member to timeout')
|
||||||
|
.setRequired(true),
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('reason')
|
||||||
|
.setDescription('The reason for the timeout')
|
||||||
|
.setRequired(true),
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('duration')
|
||||||
|
.setDescription(
|
||||||
|
'The duration of the timeout (ex. 5m, 1h, 1d, 1w). Max 28 days.',
|
||||||
|
)
|
||||||
|
.setRequired(true),
|
||||||
|
),
|
||||||
|
execute: async (interaction) => {
|
||||||
|
if (!interaction.isChatInputCommand() || !interaction.guild) return;
|
||||||
|
|
||||||
|
await interaction.deferReply({ flags: ['Ephemeral'] });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const moderator = await interaction.guild.members.fetch(
|
||||||
|
interaction.user.id,
|
||||||
|
);
|
||||||
|
const member = await interaction.guild.members.fetch(
|
||||||
|
interaction.options.get('member')!.value as string,
|
||||||
|
);
|
||||||
|
const reason = interaction.options.get('reason')?.value as string;
|
||||||
|
const muteDuration = interaction.options.get('duration')?.value as string;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!interaction.memberPermissions?.has(
|
||||||
|
PermissionsBitField.Flags.KickMembers,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: 'You do not have permission to mute members.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moderator.roles.highest.position <= member.roles.highest.position) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content:
|
||||||
|
'You cannot mute a member with equal or higher role than yours.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!member.moderatable) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: 'I do not have permission to mute this member.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationMs = parseDuration(muteDuration);
|
||||||
|
const maxTimeout = 28 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
if (durationMs > maxTimeout) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: 'Timeout duration cannot exceed 28 days.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await member.user.send(
|
||||||
|
`You have been timed out in ${interaction.guild!.name} for ${muteDuration}. Reason: ${reason}.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await member.timeout(durationMs, reason);
|
||||||
|
|
||||||
|
const expiresAt = new Date(Date.now() + durationMs);
|
||||||
|
|
||||||
|
await updateMemberModerationHistory({
|
||||||
|
discordId: member.id,
|
||||||
|
moderatorDiscordId: interaction.user.id,
|
||||||
|
action: 'mute',
|
||||||
|
reason,
|
||||||
|
duration: muteDuration,
|
||||||
|
createdAt: new Date(),
|
||||||
|
expiresAt,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateMember({
|
||||||
|
discordId: member.id,
|
||||||
|
currentlyMuted: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await logAction({
|
||||||
|
guild: interaction.guild!,
|
||||||
|
action: 'mute',
|
||||||
|
target: member,
|
||||||
|
moderator,
|
||||||
|
reason,
|
||||||
|
duration: muteDuration,
|
||||||
|
});
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `<@${member.id}> has been muted for ${muteDuration}. Reason: ${reason}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Mute command error:', error);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: 'Unable to timeout member.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default command;
|
|
@ -1,7 +1,7 @@
|
||||||
import { PermissionsBitField, SlashCommandBuilder } from 'discord.js';
|
import { PermissionsBitField, SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
import { executeUnban } from '../../util/helpers.js';
|
import { executeUnban } from '@/util/helpers.js';
|
||||||
import { OptionsCommand } from '../../types/CommandTypes.js';
|
import { OptionsCommand } from '@/types/CommandTypes.js';
|
||||||
|
|
||||||
const command: OptionsCommand = {
|
const command: OptionsCommand = {
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
|
@ -20,52 +20,54 @@ const command: OptionsCommand = {
|
||||||
.setRequired(true),
|
.setRequired(true),
|
||||||
),
|
),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
const userId = interaction.options.get('userid')!.value as string;
|
if (!interaction.isChatInputCommand() || !interaction.guild) return;
|
||||||
const reason = interaction.options.get('reason')?.value as string;
|
|
||||||
|
|
||||||
if (
|
await interaction.deferReply({ flags: ['Ephemeral'] });
|
||||||
!interaction.memberPermissions?.has(PermissionsBitField.Flags.BanMembers)
|
|
||||||
) {
|
|
||||||
await interaction.reply({
|
|
||||||
content: 'You do not have permission to unban users.',
|
|
||||||
flags: ['Ephemeral'],
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const userId = interaction.options.get('userid')?.value as string;
|
||||||
|
const reason = interaction.options.get('reason')?.value as string;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!interaction.memberPermissions?.has(
|
||||||
|
PermissionsBitField.Flags.BanMembers,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: 'You do not have permission to unban users.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ban = await interaction.guild?.bans.fetch(userId);
|
const ban = await interaction.guild.bans.fetch(userId);
|
||||||
if (!ban) {
|
if (!ban) {
|
||||||
await interaction.reply({
|
await interaction.editReply({
|
||||||
content: 'This user is not banned.',
|
content: 'This user is not banned.',
|
||||||
flags: ['Ephemeral'],
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
await interaction.reply({
|
await interaction.editReply({
|
||||||
content: 'Error getting ban. Is this user banned?',
|
content: 'Error getting ban. Is this user banned?',
|
||||||
flags: ['Ephemeral'],
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await executeUnban(
|
await executeUnban(
|
||||||
interaction.client,
|
interaction.client,
|
||||||
interaction.guildId!,
|
interaction.guild.id,
|
||||||
userId,
|
userId,
|
||||||
reason,
|
reason,
|
||||||
);
|
);
|
||||||
|
|
||||||
await interaction.reply({
|
await interaction.editReply({
|
||||||
content: `<@${userId}> has been unbanned. Reason: ${reason}`,
|
content: `<@${userId}> has been unbanned. Reason: ${reason}`,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(`Unable to unban user: ${error}`);
|
||||||
await interaction.reply({
|
await interaction.editReply({
|
||||||
content: 'Unable to unban user.',
|
content: 'Unable to unban user.',
|
||||||
flags: ['Ephemeral'],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
66
src/commands/moderation/unmute.ts
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
import { PermissionsBitField, SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
|
import { executeUnmute } from '@/util/helpers.js';
|
||||||
|
import { OptionsCommand } from '@/types/CommandTypes.js';
|
||||||
|
|
||||||
|
const command: OptionsCommand = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('unmute')
|
||||||
|
.setDescription('Remove a timeout from a member')
|
||||||
|
.addUserOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('member')
|
||||||
|
.setDescription('The member to unmute')
|
||||||
|
.setRequired(true),
|
||||||
|
)
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('reason')
|
||||||
|
.setDescription('The reason for removing the timeout')
|
||||||
|
.setRequired(true),
|
||||||
|
),
|
||||||
|
execute: async (interaction) => {
|
||||||
|
if (!interaction.isChatInputCommand() || !interaction.guild) return;
|
||||||
|
|
||||||
|
await interaction.deferReply({ flags: ['Ephemeral'] });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const moderator = await interaction.guild.members.fetch(
|
||||||
|
interaction.user.id,
|
||||||
|
);
|
||||||
|
const member = await interaction.guild.members.fetch(
|
||||||
|
interaction.options.get('member')!.value as string,
|
||||||
|
);
|
||||||
|
const reason = interaction.options.get('reason')?.value as string;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!interaction.memberPermissions?.has(
|
||||||
|
PermissionsBitField.Flags.ModerateMembers,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: 'You do not have permission to unmute members.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await executeUnmute(
|
||||||
|
interaction.client,
|
||||||
|
interaction.guild.id,
|
||||||
|
member.id,
|
||||||
|
reason,
|
||||||
|
moderator,
|
||||||
|
);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
content: `<@${member.id}>'s timeout has been removed. Reason: ${reason}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Unmute command error:', error);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: 'Unable to unmute member.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default command;
|
|
@ -1,8 +1,8 @@
|
||||||
import { PermissionsBitField, SlashCommandBuilder } from 'discord.js';
|
import { PermissionsBitField, SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
import { updateMemberModerationHistory } from '../../db/db.js';
|
import { updateMemberModerationHistory } from '@/db/db.js';
|
||||||
import { OptionsCommand } from '../../types/CommandTypes.js';
|
import { OptionsCommand } from '@/types/CommandTypes.js';
|
||||||
import logAction from '../../util/logging/logAction.js';
|
import logAction from '@/util/logging/logAction.js';
|
||||||
|
|
||||||
const command: OptionsCommand = {
|
const command: OptionsCommand = {
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
|
@ -21,29 +21,38 @@ const command: OptionsCommand = {
|
||||||
.setRequired(true),
|
.setRequired(true),
|
||||||
),
|
),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
const moderator = await interaction.guild?.members.fetch(
|
if (!interaction.isChatInputCommand() || !interaction.guild) return;
|
||||||
interaction.user.id,
|
|
||||||
);
|
|
||||||
const member = await interaction.guild?.members.fetch(
|
|
||||||
interaction.options.get('member')!.value as unknown as string,
|
|
||||||
);
|
|
||||||
const reason = interaction.options.get('reason')
|
|
||||||
?.value as unknown as string;
|
|
||||||
|
|
||||||
if (
|
await interaction.deferReply({ flags: ['Ephemeral'] });
|
||||||
!interaction.memberPermissions?.has(
|
|
||||||
PermissionsBitField.Flags.ModerateMembers,
|
|
||||||
) ||
|
|
||||||
moderator!.roles.highest.position <= member!.roles.highest.position
|
|
||||||
) {
|
|
||||||
await interaction.reply({
|
|
||||||
content: 'You do not have permission to warn this member.',
|
|
||||||
flags: ['Ephemeral'],
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const moderator = await interaction.guild.members.fetch(
|
||||||
|
interaction.user.id,
|
||||||
|
);
|
||||||
|
const member = await interaction.guild.members.fetch(
|
||||||
|
interaction.options.get('member')!.value as unknown as string,
|
||||||
|
);
|
||||||
|
const reason = interaction.options.getString('reason')!;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!interaction.memberPermissions?.has(
|
||||||
|
PermissionsBitField.Flags.ModerateMembers,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: 'You do not have permission to warn members.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moderator.roles.highest.position <= member.roles.highest.position) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content:
|
||||||
|
'You cannot warn a member with equal or higher role than yours.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await updateMemberModerationHistory({
|
await updateMemberModerationHistory({
|
||||||
discordId: member!.user.id,
|
discordId: member!.user.id,
|
||||||
moderatorDiscordId: interaction.user.id,
|
moderatorDiscordId: interaction.user.id,
|
||||||
|
@ -54,9 +63,6 @@ const command: OptionsCommand = {
|
||||||
await member!.user.send(
|
await member!.user.send(
|
||||||
`You have been warned in **${interaction?.guild?.name}**. Reason: **${reason}**.`,
|
`You have been warned in **${interaction?.guild?.name}**. Reason: **${reason}**.`,
|
||||||
);
|
);
|
||||||
await interaction.reply(
|
|
||||||
`<@${member!.user.id}> has been warned. Reason: ${reason}`,
|
|
||||||
);
|
|
||||||
await logAction({
|
await logAction({
|
||||||
guild: interaction.guild!,
|
guild: interaction.guild!,
|
||||||
action: 'warn',
|
action: 'warn',
|
||||||
|
@ -64,11 +70,13 @@ const command: OptionsCommand = {
|
||||||
moderator: moderator!,
|
moderator: moderator!,
|
||||||
reason: reason,
|
reason: reason,
|
||||||
});
|
});
|
||||||
|
await interaction.editReply(
|
||||||
|
`<@${member!.user.id}> has been warned. Reason: ${reason}`,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
await interaction.reply({
|
await interaction.editReply({
|
||||||
content: 'There was an error trying to warn the member.',
|
content: 'There was an error trying to warn the member.',
|
||||||
flags: ['Ephemeral'],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { PermissionsBitField, SlashCommandBuilder } from 'discord.js';
|
import { PermissionsBitField, SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
import { Command } from '../../types/CommandTypes.js';
|
import { Command } from '@/types/CommandTypes.js';
|
||||||
|
|
||||||
const command: Command = {
|
const command: Command = {
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
|
@ -8,25 +8,27 @@ const command: Command = {
|
||||||
.setDescription('Simulates a new member joining'),
|
.setDescription('Simulates a new member joining'),
|
||||||
|
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
|
if (!interaction.isChatInputCommand() || !interaction.guild) return;
|
||||||
const guild = interaction.guild;
|
const guild = interaction.guild;
|
||||||
|
|
||||||
|
await interaction.deferReply({ flags: ['Ephemeral'] });
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!interaction.memberPermissions!.has(
|
!interaction.memberPermissions!.has(
|
||||||
PermissionsBitField.Flags.Administrator,
|
PermissionsBitField.Flags.Administrator,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
await interaction.reply({
|
await interaction.editReply({
|
||||||
content: 'You do not have permission to use this command.',
|
content: 'You do not have permission to use this command.',
|
||||||
flags: ['Ephemeral'],
|
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fakeMember = await guild!.members.fetch(interaction.user.id);
|
const fakeMember = await guild.members.fetch(interaction.user.id);
|
||||||
guild!.client.emit('guildMemberAdd', fakeMember);
|
guild.client.emit('guildMemberAdd', fakeMember);
|
||||||
|
|
||||||
await interaction.reply({
|
await interaction.editReply({
|
||||||
content: 'Triggered the join event!',
|
content: 'Triggered the join event!',
|
||||||
flags: ['Ephemeral'],
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { PermissionsBitField, SlashCommandBuilder } from 'discord.js';
|
import { PermissionsBitField, SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
import { updateMember } from '../../db/db.js';
|
import { updateMember } from '@/db/db.js';
|
||||||
import { Command } from '../../types/CommandTypes.js';
|
import { Command } from '@/types/CommandTypes.js';
|
||||||
|
|
||||||
const command: Command = {
|
const command: Command = {
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
|
@ -9,25 +9,26 @@ const command: Command = {
|
||||||
.setDescription('Simulates a member leaving'),
|
.setDescription('Simulates a member leaving'),
|
||||||
|
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
|
if (!interaction.isChatInputCommand() || !interaction.guild) return;
|
||||||
const guild = interaction.guild;
|
const guild = interaction.guild;
|
||||||
|
|
||||||
|
await interaction.deferReply({ flags: ['Ephemeral'] });
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!interaction.memberPermissions!.has(
|
!interaction.memberPermissions!.has(
|
||||||
PermissionsBitField.Flags.Administrator,
|
PermissionsBitField.Flags.Administrator,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
await interaction.reply({
|
await interaction.editReply({
|
||||||
content: 'You do not have permission to use this command.',
|
content: 'You do not have permission to use this command.',
|
||||||
flags: ['Ephemeral'],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const fakeMember = await guild!.members.fetch(interaction.user.id);
|
const fakeMember = await guild.members.fetch(interaction.user.id);
|
||||||
guild!.client.emit('guildMemberRemove', fakeMember);
|
guild.client.emit('guildMemberRemove', fakeMember);
|
||||||
|
|
||||||
await interaction.reply({
|
await interaction.editReply({
|
||||||
content: 'Triggered the leave event!',
|
content: 'Triggered the leave event!',
|
||||||
flags: ['Ephemeral'],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await updateMember({
|
await updateMember({
|
||||||
|
|
237
src/commands/util/config.ts
Normal file
|
@ -0,0 +1,237 @@
|
||||||
|
import {
|
||||||
|
SlashCommandBuilder,
|
||||||
|
EmbedBuilder,
|
||||||
|
PermissionFlagsBits,
|
||||||
|
} from 'discord.js';
|
||||||
|
|
||||||
|
import { Command } from '@/types/CommandTypes.js';
|
||||||
|
import { loadConfig } from '@/util/configLoader.js';
|
||||||
|
import { createPaginationButtons } from '@/util/helpers.js';
|
||||||
|
|
||||||
|
const command: Command = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('config')
|
||||||
|
.setDescription('(Admin Only) Display the current configuration')
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
execute: async (interaction) => {
|
||||||
|
if (!interaction.isChatInputCommand() || !interaction.guild) return;
|
||||||
|
|
||||||
|
await interaction.deferReply({ flags: ['Ephemeral'] });
|
||||||
|
|
||||||
|
if (
|
||||||
|
!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)
|
||||||
|
) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: 'You do not have permission to use this command.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
const displayConfig = JSON.parse(JSON.stringify(config));
|
||||||
|
|
||||||
|
if (displayConfig.token) displayConfig.token = '••••••••••••••••••••••••••';
|
||||||
|
if (displayConfig.database?.dbConnectionString) {
|
||||||
|
displayConfig.database.dbConnectionString = '••••••••••••••••••••••••••';
|
||||||
|
}
|
||||||
|
if (displayConfig.redis?.redisConnectionString) {
|
||||||
|
displayConfig.redis.redisConnectionString = '••••••••••••••••••••••••••';
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages: EmbedBuilder[] = [];
|
||||||
|
|
||||||
|
const basicConfigEmbed = new EmbedBuilder()
|
||||||
|
.setColor(0x0099ff)
|
||||||
|
.setTitle('Bot Configuration')
|
||||||
|
.setDescription(
|
||||||
|
'Current configuration settings (sensitive data redacted)',
|
||||||
|
)
|
||||||
|
.addFields(
|
||||||
|
{
|
||||||
|
name: 'Client ID',
|
||||||
|
value: displayConfig.clientId || 'Not set',
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Guild ID',
|
||||||
|
value: displayConfig.guildId || 'Not set',
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Token',
|
||||||
|
value: displayConfig.token || 'Not set',
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
pages.push(basicConfigEmbed);
|
||||||
|
|
||||||
|
if (displayConfig.database || displayConfig.redis) {
|
||||||
|
const dbRedisEmbed = new EmbedBuilder()
|
||||||
|
.setColor(0x0099ff)
|
||||||
|
.setTitle('Database and Redis Configuration')
|
||||||
|
.setDescription('Database and cache settings');
|
||||||
|
|
||||||
|
if (displayConfig.database) {
|
||||||
|
dbRedisEmbed.addFields({
|
||||||
|
name: 'Database',
|
||||||
|
value: `Connection: ${displayConfig.database.dbConnectionString}\nMax Retry: ${displayConfig.database.maxRetryAttempts}\nRetry Delay: ${displayConfig.database.retryDelay}ms`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayConfig.redis) {
|
||||||
|
dbRedisEmbed.addFields({
|
||||||
|
name: 'Redis',
|
||||||
|
value: `Connection: ${displayConfig.redis.redisConnectionString}\nRetry Attempts: ${displayConfig.redis.retryAttempts}\nInitial Retry Delay: ${displayConfig.redis.initialRetryDelay}ms`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pages.push(dbRedisEmbed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayConfig.channels || displayConfig.roles) {
|
||||||
|
const channelsRolesEmbed = new EmbedBuilder()
|
||||||
|
.setColor(0x0099ff)
|
||||||
|
.setTitle('Channels and Roles Configuration')
|
||||||
|
.setDescription('Server channel and role settings');
|
||||||
|
|
||||||
|
if (displayConfig.channels) {
|
||||||
|
const channelsText = Object.entries(displayConfig.channels)
|
||||||
|
.map(([key, value]) => `${key}: ${value}`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
channelsRolesEmbed.addFields({
|
||||||
|
name: 'Channels',
|
||||||
|
value: channelsText || 'None configured',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayConfig.roles) {
|
||||||
|
let rolesText = '';
|
||||||
|
|
||||||
|
if (displayConfig.roles.joinRoles?.length) {
|
||||||
|
rolesText += `Join Roles: ${displayConfig.roles.joinRoles.join(', ')}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayConfig.roles.levelRoles?.length) {
|
||||||
|
rolesText += `Level Roles: ${displayConfig.roles.levelRoles.length} configured\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayConfig.roles.staffRoles?.length) {
|
||||||
|
rolesText += `Staff Roles: ${displayConfig.roles.staffRoles.length} configured\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayConfig.roles.factPingRole) {
|
||||||
|
rolesText += `Fact Ping Role: ${displayConfig.roles.factPingRole}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
channelsRolesEmbed.addFields({
|
||||||
|
name: 'Roles',
|
||||||
|
value: rolesText || 'None configured',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pages.push(channelsRolesEmbed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
displayConfig.leveling ||
|
||||||
|
displayConfig.counting ||
|
||||||
|
displayConfig.giveaways
|
||||||
|
) {
|
||||||
|
const featuresEmbed = new EmbedBuilder()
|
||||||
|
.setColor(0x0099ff)
|
||||||
|
.setTitle('Feature Configurations')
|
||||||
|
.setDescription('Settings for bot features');
|
||||||
|
|
||||||
|
if (displayConfig.leveling) {
|
||||||
|
featuresEmbed.addFields({
|
||||||
|
name: 'Leveling',
|
||||||
|
value: `XP Cooldown: ${displayConfig.leveling.xpCooldown}s\nMin XP: ${displayConfig.leveling.minXpAwarded}\nMax XP: ${displayConfig.leveling.maxXpAwarded}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayConfig.counting) {
|
||||||
|
const countingText = Object.entries(displayConfig.counting)
|
||||||
|
.map(([key, value]) => `${key}: ${value}`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
featuresEmbed.addFields({
|
||||||
|
name: 'Counting',
|
||||||
|
value: countingText || 'Default settings',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayConfig.giveaways) {
|
||||||
|
const giveawaysText = Object.entries(displayConfig.giveaways)
|
||||||
|
.map(([key, value]) => `${key}: ${value}`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
featuresEmbed.addFields({
|
||||||
|
name: 'Giveaways',
|
||||||
|
value: giveawaysText || 'Default settings',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pages.push(featuresEmbed);
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentPage = 0;
|
||||||
|
|
||||||
|
const components =
|
||||||
|
pages.length > 1
|
||||||
|
? [createPaginationButtons(pages.length, currentPage)]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const reply = await interaction.editReply({
|
||||||
|
embeds: [pages[currentPage]],
|
||||||
|
components,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pages.length <= 1) return;
|
||||||
|
|
||||||
|
const collector = reply.createMessageComponentCollector({
|
||||||
|
time: 300000,
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on('collect', async (i) => {
|
||||||
|
if (i.user.id !== interaction.user.id) {
|
||||||
|
await i.reply({
|
||||||
|
content: 'You cannot use these buttons.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (i.customId) {
|
||||||
|
case 'first':
|
||||||
|
currentPage = 0;
|
||||||
|
break;
|
||||||
|
case 'prev':
|
||||||
|
if (currentPage > 0) currentPage--;
|
||||||
|
break;
|
||||||
|
case 'next':
|
||||||
|
if (currentPage < pages.length - 1) currentPage++;
|
||||||
|
break;
|
||||||
|
case 'last':
|
||||||
|
currentPage = pages.length - 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await i.update({
|
||||||
|
embeds: [pages[currentPage]],
|
||||||
|
components: [createPaginationButtons(pages.length, currentPage)],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on('end', async () => {
|
||||||
|
try {
|
||||||
|
await interaction.editReply({ components: [] });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to remove pagination buttons:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default command;
|
273
src/commands/util/help.ts
Normal file
|
@ -0,0 +1,273 @@
|
||||||
|
import {
|
||||||
|
SlashCommandBuilder,
|
||||||
|
EmbedBuilder,
|
||||||
|
ActionRowBuilder,
|
||||||
|
StringSelectMenuBuilder,
|
||||||
|
StringSelectMenuOptionBuilder,
|
||||||
|
ComponentType,
|
||||||
|
} from 'discord.js';
|
||||||
|
|
||||||
|
import { OptionsCommand } from '@/types/CommandTypes.js';
|
||||||
|
import { ExtendedClient } from '@/structures/ExtendedClient.js';
|
||||||
|
|
||||||
|
const DOC_BASE_URL = 'https://docs.poixpixel.ahmadk953.org/';
|
||||||
|
const getDocUrl = (location: string) =>
|
||||||
|
`${DOC_BASE_URL}?utm_source=discord&utm_medium=bot&utm_campaign=help_command&utm_content=${location}`;
|
||||||
|
|
||||||
|
const command: OptionsCommand = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('help')
|
||||||
|
.setDescription('Shows a list of all available commands')
|
||||||
|
.addStringOption((option) =>
|
||||||
|
option
|
||||||
|
.setName('command')
|
||||||
|
.setDescription('Get detailed help for a specific command')
|
||||||
|
.setRequired(false),
|
||||||
|
),
|
||||||
|
|
||||||
|
execute: async (interaction) => {
|
||||||
|
if (!interaction.isChatInputCommand() || !interaction.guild) return;
|
||||||
|
|
||||||
|
await interaction.deferReply();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = interaction.client as ExtendedClient;
|
||||||
|
const commandName = interaction.options.getString('command');
|
||||||
|
|
||||||
|
if (commandName) {
|
||||||
|
return handleSpecificCommand(interaction, client, commandName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories = new Map();
|
||||||
|
|
||||||
|
for (const [name, cmd] of client.commands) {
|
||||||
|
const category = getCategoryFromCommand(name);
|
||||||
|
|
||||||
|
if (!categories.has(category)) {
|
||||||
|
categories.set(category, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
categories.get(category).push({
|
||||||
|
name,
|
||||||
|
description: cmd.data.toJSON().description,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setColor('#0099ff')
|
||||||
|
.setTitle('Poixpixel Bot Commands')
|
||||||
|
.setDescription(
|
||||||
|
'**Welcome to Poixpixel Discord Bot!**\n\n' +
|
||||||
|
'Select a category from the dropdown menu below to see available commands.\n\n' +
|
||||||
|
`📚 **Documentation:** [Visit Our Documentation](${getDocUrl('main_description')})`,
|
||||||
|
)
|
||||||
|
.setThumbnail(client.user!.displayAvatarURL())
|
||||||
|
.setFooter({
|
||||||
|
text: 'Use /help [command] for detailed info about a command',
|
||||||
|
});
|
||||||
|
|
||||||
|
const categoryEmojis: Record<string, string> = {
|
||||||
|
fun: '🎮',
|
||||||
|
moderation: '🛡️',
|
||||||
|
util: '🔧',
|
||||||
|
testing: '🧪',
|
||||||
|
};
|
||||||
|
|
||||||
|
Array.from(categories.keys()).forEach((category) => {
|
||||||
|
const emoji = categoryEmojis[category] || '📁';
|
||||||
|
embed.addFields({
|
||||||
|
name: `${emoji} ${category.charAt(0).toUpperCase() + category.slice(1)}`,
|
||||||
|
value: `Use the dropdown to see ${category} commands`,
|
||||||
|
inline: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
embed.addFields({
|
||||||
|
name: '📚 Documentation',
|
||||||
|
value: `[Click here to access our full documentation](${getDocUrl('main_footer_field')})`,
|
||||||
|
inline: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectMenu =
|
||||||
|
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
||||||
|
new StringSelectMenuBuilder()
|
||||||
|
.setCustomId('help_category_select')
|
||||||
|
.setPlaceholder('Select a command category')
|
||||||
|
.addOptions(
|
||||||
|
Array.from(categories.keys()).map((category) => {
|
||||||
|
const emoji = categoryEmojis[category] || '📁';
|
||||||
|
return new StringSelectMenuOptionBuilder()
|
||||||
|
.setLabel(
|
||||||
|
category.charAt(0).toUpperCase() + category.slice(1),
|
||||||
|
)
|
||||||
|
.setDescription(`View ${category} commands`)
|
||||||
|
.setValue(category)
|
||||||
|
.setEmoji(emoji);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const message = await interaction.editReply({
|
||||||
|
embeds: [embed],
|
||||||
|
components: [selectMenu],
|
||||||
|
});
|
||||||
|
|
||||||
|
const collector = message.createMessageComponentCollector({
|
||||||
|
componentType: ComponentType.StringSelect,
|
||||||
|
time: 60000,
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on('collect', async (i) => {
|
||||||
|
if (i.user.id !== interaction.user.id) {
|
||||||
|
await i.reply({
|
||||||
|
content: 'You cannot use this menu.',
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedCategory = i.values[0];
|
||||||
|
const commands = categories.get(selectedCategory);
|
||||||
|
const emoji = categoryEmojis[selectedCategory] || '📁';
|
||||||
|
|
||||||
|
const categoryEmbed = new EmbedBuilder()
|
||||||
|
.setColor('#0099ff')
|
||||||
|
.setTitle(
|
||||||
|
`${emoji} ${selectedCategory.charAt(0).toUpperCase() + selectedCategory.slice(1)} Commands`,
|
||||||
|
)
|
||||||
|
.setDescription('Here are all the commands in this category:')
|
||||||
|
.setFooter({
|
||||||
|
text: 'Use /help [command] for detailed info about a command',
|
||||||
|
});
|
||||||
|
|
||||||
|
commands.forEach((cmd: any) => {
|
||||||
|
categoryEmbed.addFields({
|
||||||
|
name: `/${cmd.name}`,
|
||||||
|
value: cmd.description || 'No description available',
|
||||||
|
inline: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
categoryEmbed.addFields({
|
||||||
|
name: '📚 Documentation',
|
||||||
|
value: `[Click here to access our full documentation](${getDocUrl(`category_${selectedCategory}`)})`,
|
||||||
|
inline: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await i.update({ embeds: [categoryEmbed], components: [selectMenu] });
|
||||||
|
});
|
||||||
|
|
||||||
|
collector.on('end', () => {
|
||||||
|
interaction.editReply({ components: [] }).catch(console.error);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in help command:', error);
|
||||||
|
await interaction.editReply({
|
||||||
|
content: 'An error occurred while processing your request.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle showing help for a specific command
|
||||||
|
*/
|
||||||
|
async function handleSpecificCommand(
|
||||||
|
interaction: any,
|
||||||
|
client: ExtendedClient,
|
||||||
|
commandName: string,
|
||||||
|
) {
|
||||||
|
const cmd = client.commands.get(commandName);
|
||||||
|
|
||||||
|
if (!cmd) {
|
||||||
|
return interaction.editReply({
|
||||||
|
content: `Command \`${commandName}\` not found.`,
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setColor('#0099ff')
|
||||||
|
.setTitle(`Help: /${commandName}`)
|
||||||
|
.setDescription(cmd.data.toJSON().description || 'No description available')
|
||||||
|
.addFields({
|
||||||
|
name: 'Category',
|
||||||
|
value: getCategoryFromCommand(commandName),
|
||||||
|
inline: true,
|
||||||
|
})
|
||||||
|
.setFooter({
|
||||||
|
text: `Poixpixel Discord Bot • Documentation: ${getDocUrl(`cmd_footer_${commandName}`)}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const options = cmd.data.toJSON().options;
|
||||||
|
if (options && options.length > 0) {
|
||||||
|
if (options[0].type === 1) {
|
||||||
|
embed.addFields({
|
||||||
|
name: 'Subcommands',
|
||||||
|
value: options
|
||||||
|
.map((opt: any) => `\`${opt.name}\`: ${opt.description}`)
|
||||||
|
.join('\n'),
|
||||||
|
inline: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
embed.addFields({
|
||||||
|
name: 'Options',
|
||||||
|
value: options
|
||||||
|
.map(
|
||||||
|
(opt: any) =>
|
||||||
|
`\`${opt.name}\`: ${opt.description} ${opt.required ? '(Required)' : '(Optional)'}`,
|
||||||
|
)
|
||||||
|
.join('\n'),
|
||||||
|
inline: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
embed.addFields({
|
||||||
|
name: '📚 Documentation',
|
||||||
|
value: `[Click here to access our full documentation](${getDocUrl(`cmd_field_${commandName}`)})`,
|
||||||
|
inline: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return interaction.editReply({ embeds: [embed] });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the category of a command based on its name
|
||||||
|
*/
|
||||||
|
function getCategoryFromCommand(commandName: string): string {
|
||||||
|
const commandCategories: Record<string, string> = {
|
||||||
|
achievement: 'fun',
|
||||||
|
fact: 'fun',
|
||||||
|
rank: 'fun',
|
||||||
|
counting: 'fun',
|
||||||
|
giveaway: 'fun',
|
||||||
|
leaderboard: 'fun',
|
||||||
|
|
||||||
|
ban: 'moderation',
|
||||||
|
kick: 'moderation',
|
||||||
|
mute: 'moderation',
|
||||||
|
unmute: 'moderation',
|
||||||
|
warn: 'moderation',
|
||||||
|
unban: 'moderation',
|
||||||
|
|
||||||
|
ping: 'util',
|
||||||
|
server: 'util',
|
||||||
|
userinfo: 'util',
|
||||||
|
members: 'util',
|
||||||
|
rules: 'util',
|
||||||
|
restart: 'util',
|
||||||
|
reconnect: 'util',
|
||||||
|
xp: 'util',
|
||||||
|
recalculatelevels: 'util',
|
||||||
|
help: 'util',
|
||||||
|
config: 'util',
|
||||||
|
|
||||||
|
testjoin: 'testing',
|
||||||
|
testleave: 'testing',
|
||||||
|
};
|
||||||
|
|
||||||
|
return commandCategories[commandName.toLowerCase()] || 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default command;
|
|
@ -1,25 +1,28 @@
|
||||||
import {
|
import {
|
||||||
SlashCommandBuilder,
|
SlashCommandBuilder,
|
||||||
EmbedBuilder,
|
EmbedBuilder,
|
||||||
ButtonBuilder,
|
|
||||||
ActionRowBuilder,
|
ActionRowBuilder,
|
||||||
ButtonStyle,
|
|
||||||
StringSelectMenuBuilder,
|
StringSelectMenuBuilder,
|
||||||
APIEmbed,
|
APIEmbed,
|
||||||
JSONEncodable,
|
JSONEncodable,
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
|
|
||||||
import { getAllMembers } from '../../db/db.js';
|
import { getAllMembers } from '@/db/db.js';
|
||||||
import { Command } from '../../types/CommandTypes.js';
|
import { Command } from '@/types/CommandTypes.js';
|
||||||
|
import { createPaginationButtons } from '@/util/helpers.js';
|
||||||
|
|
||||||
const command: Command = {
|
const command: Command = {
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
.setName('members')
|
.setName('members')
|
||||||
.setDescription('Lists all non-bot members of the server'),
|
.setDescription('Lists all non-bot members of the server'),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
|
if (!interaction.isChatInputCommand() || !interaction.guild) return;
|
||||||
|
|
||||||
|
await interaction.deferReply();
|
||||||
|
|
||||||
let members = await getAllMembers();
|
let members = await getAllMembers();
|
||||||
members = members.sort((a, b) =>
|
members = members.sort((a, b) =>
|
||||||
a.discordUsername.localeCompare(b.discordUsername),
|
(a.discordUsername ?? '').localeCompare(b.discordUsername ?? ''),
|
||||||
);
|
);
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 15;
|
const ITEMS_PER_PAGE = 15;
|
||||||
|
@ -42,18 +45,7 @@ const command: Command = {
|
||||||
|
|
||||||
let currentPage = 0;
|
let currentPage = 0;
|
||||||
const getButtonActionRow = () =>
|
const getButtonActionRow = () =>
|
||||||
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
createPaginationButtons(pages.length, currentPage);
|
||||||
new ButtonBuilder()
|
|
||||||
.setCustomId('previous')
|
|
||||||
.setLabel('Previous')
|
|
||||||
.setStyle(ButtonStyle.Primary)
|
|
||||||
.setDisabled(currentPage === 0),
|
|
||||||
new ButtonBuilder()
|
|
||||||
.setCustomId('next')
|
|
||||||
.setLabel('Next')
|
|
||||||
.setStyle(ButtonStyle.Primary)
|
|
||||||
.setDisabled(currentPage === pages.length - 1),
|
|
||||||
);
|
|
||||||
|
|
||||||
const getSelectMenuRow = () => {
|
const getSelectMenuRow = () => {
|
||||||
const options = pages.map((_, index) => ({
|
const options = pages.map((_, index) => ({
|
||||||
|
@ -75,7 +67,7 @@ const command: Command = {
|
||||||
const components =
|
const components =
|
||||||
pages.length > 1 ? [getButtonActionRow(), getSelectMenuRow()] : [];
|
pages.length > 1 ? [getButtonActionRow(), getSelectMenuRow()] : [];
|
||||||
|
|
||||||
await interaction.reply({
|
await interaction.editReply({
|
||||||
embeds: [pages[currentPage]],
|
embeds: [pages[currentPage]],
|
||||||
components,
|
components,
|
||||||
});
|
});
|
||||||
|
@ -85,7 +77,7 @@ const command: Command = {
|
||||||
if (pages.length <= 1) return;
|
if (pages.length <= 1) return;
|
||||||
|
|
||||||
const collector = message.createMessageComponentCollector({
|
const collector = message.createMessageComponentCollector({
|
||||||
time: 60000,
|
time: 300000,
|
||||||
});
|
});
|
||||||
|
|
||||||
collector.on('collect', async (i) => {
|
collector.on('collect', async (i) => {
|
||||||
|
@ -98,10 +90,19 @@ const command: Command = {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (i.isButton()) {
|
if (i.isButton()) {
|
||||||
if (i.customId === 'previous' && currentPage > 0) {
|
switch (i.customId) {
|
||||||
currentPage--;
|
case 'first':
|
||||||
} else if (i.customId === 'next' && currentPage < pages.length - 1) {
|
currentPage = 0;
|
||||||
currentPage++;
|
break;
|
||||||
|
case 'prev':
|
||||||
|
if (currentPage > 0) currentPage--;
|
||||||
|
break;
|
||||||
|
case 'next':
|
||||||
|
if (currentPage < pages.length - 1) currentPage++;
|
||||||
|
break;
|
||||||
|
case 'last':
|
||||||
|
currentPage = pages.length - 1;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { SlashCommandBuilder } from 'discord.js';
|
import { SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
import { Command } from '../../types/CommandTypes.js';
|
import { Command } from '@/types/CommandTypes.js';
|
||||||
|
|
||||||
const command: Command = {
|
const command: Command = {
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
|
@ -8,7 +8,7 @@ const command: Command = {
|
||||||
.setDescription('Check the latency from you to the bot'),
|
.setDescription('Check the latency from you to the bot'),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.reply(
|
await interaction.reply(
|
||||||
`Pong! Latency: ${Date.now() - interaction.createdTimestamp}ms`,
|
`🏓 Pong! Latency: ${Date.now() - interaction.createdTimestamp}ms`,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
37
src/commands/util/recalculatelevels.ts
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
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.isChatInputCommand() || !interaction.guild) return;
|
||||||
|
|
||||||
|
await interaction.deferReply({ flags: ['Ephemeral'] });
|
||||||
|
await interaction.editReply('Recalculating levels...');
|
||||||
|
|
||||||
|
if (
|
||||||
|
!interaction.memberPermissions?.has(
|
||||||
|
PermissionsBitField.Flags.Administrator,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await interaction.editReply({
|
||||||
|
content: 'You do not have permission to use this command.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
199
src/commands/util/reconnect.ts
Normal file
|
@ -0,0 +1,199 @@
|
||||||
|
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() || !interaction.guild) return;
|
||||||
|
|
||||||
|
await interaction.deferReply({ flags: ['Ephemeral'] });
|
||||||
|
|
||||||
|
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.editReply({
|
||||||
|
content:
|
||||||
|
'You do not have permission to use this command. This command is restricted to users with the Manager role.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
|
|
||||||
|
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;
|
95
src/commands/util/restart.ts
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
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) => {
|
||||||
|
if (!interaction.isChatInputCommand() || !interaction.guild) return;
|
||||||
|
|
||||||
|
await interaction.deferReply({ flags: ['Ephemeral'] });
|
||||||
|
|
||||||
|
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.editReply({
|
||||||
|
content:
|
||||||
|
'You do not have permission to restart the bot. This command is restricted to users with the Manager role.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
content: 'Restarting the bot... This may take a few moments.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const dbConnected = await ensureDatabaseConnection();
|
||||||
|
const redisConnected = isRedisConnected();
|
||||||
|
let statusInfo = '';
|
||||||
|
|
||||||
|
if (!dbConnected) {
|
||||||
|
statusInfo += '⚠️ Database is currently disconnected\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!redisConnected) {
|
||||||
|
statusInfo += '⚠️ Redis caching is currently unavailable\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dbConnected && redisConnected) {
|
||||||
|
statusInfo = '✅ All services are operational\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
await notifyManagers(
|
||||||
|
interaction.client,
|
||||||
|
NotificationType.BOT_RESTARTING,
|
||||||
|
`Restart initiated by ${interaction.user.tag}\n\nCurrent service status:\n${statusInfo}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
console.log(
|
||||||
|
`Bot restart initiated by ${interaction.user.tag} (${interaction.user.id})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await execAsync('yarn restart');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to restart the bot:', error);
|
||||||
|
try {
|
||||||
|
await interaction.followUp({
|
||||||
|
content:
|
||||||
|
'Failed to restart the bot. Check the console for details.',
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// If this fails too, we can't do much
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default command;
|
|
@ -1,6 +1,6 @@
|
||||||
import { SlashCommandBuilder, EmbedBuilder } from 'discord.js';
|
import { SlashCommandBuilder, EmbedBuilder } from 'discord.js';
|
||||||
|
|
||||||
import { Command } from '../../types/CommandTypes.js';
|
import { Command } from '@/types/CommandTypes.js';
|
||||||
|
|
||||||
const rulesEmbed = new EmbedBuilder()
|
const rulesEmbed = new EmbedBuilder()
|
||||||
.setColor(0x0099ff)
|
.setColor(0x0099ff)
|
||||||
|
|
|
@ -1,14 +1,16 @@
|
||||||
import { SlashCommandBuilder } from 'discord.js';
|
import { SlashCommandBuilder } from 'discord.js';
|
||||||
|
|
||||||
import { Command } from '../../types/CommandTypes.js';
|
import { Command } from '@/types/CommandTypes.js';
|
||||||
|
|
||||||
const command: Command = {
|
const command: Command = {
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
.setName('server')
|
.setName('server')
|
||||||
.setDescription('Provides information about the server.'),
|
.setDescription('Provides information about the server.'),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
|
if (!interaction.isChatInputCommand() || !interaction.guild) return;
|
||||||
|
|
||||||
await interaction.reply(
|
await interaction.reply(
|
||||||
`The server **${interaction!.guild!.name}** has **${interaction!.guild!.memberCount}** members and was created on **${interaction!.guild!.createdAt}**. It is **${new Date().getFullYear() - interaction!.guild!.createdAt.getFullYear()!}** years old.`,
|
`The server **${interaction.guild.name}** has **${interaction.guild.memberCount}** members and was created on **${interaction.guild.createdAt}**. It is **${new Date().getFullYear() - interaction.guild.createdAt.getFullYear()}** years old.`,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -5,8 +5,8 @@ import {
|
||||||
PermissionsBitField,
|
PermissionsBitField,
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
|
|
||||||
import { getMember } from '../../db/db.js';
|
import { getMember } from '@/db/db.js';
|
||||||
import { OptionsCommand } from '../../types/CommandTypes.js';
|
import { OptionsCommand } from '@/types/CommandTypes.js';
|
||||||
|
|
||||||
const command: OptionsCommand = {
|
const command: OptionsCommand = {
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
|
@ -19,21 +19,25 @@ const command: OptionsCommand = {
|
||||||
.setRequired(true),
|
.setRequired(true),
|
||||||
),
|
),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
|
if (!interaction.isChatInputCommand() || !interaction.guild) return;
|
||||||
|
|
||||||
|
await interaction.deferReply();
|
||||||
|
|
||||||
const userOption = interaction.options.get(
|
const userOption = interaction.options.get(
|
||||||
'user',
|
'user',
|
||||||
) as unknown as GuildMember;
|
) as unknown as GuildMember;
|
||||||
const user = userOption.user;
|
const user = userOption.user;
|
||||||
|
|
||||||
if (!userOption || !user) {
|
if (!userOption || !user) {
|
||||||
await interaction.reply('User not found');
|
await interaction.editReply('User not found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!interaction.memberPermissions!.has(
|
!interaction.memberPermissions?.has(
|
||||||
PermissionsBitField.Flags.ModerateMembers,
|
PermissionsBitField.Flags.ModerateMembers,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
await interaction.reply(
|
await interaction.editReply(
|
||||||
'You do not have permission to view member information.',
|
'You do not have permission to view member information.',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
@ -140,7 +144,7 @@ const command: OptionsCommand = {
|
||||||
iconURL: interaction.user.displayAvatarURL(),
|
iconURL: interaction.user.displayAvatarURL(),
|
||||||
});
|
});
|
||||||
|
|
||||||
await interaction.reply({ embeds: [embed] });
|
await interaction.editReply({ embeds: [embed] });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
130
src/commands/util/xp.ts
Normal file
|
@ -0,0 +1,130 @@
|
||||||
|
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() || !interaction.guild) return;
|
||||||
|
|
||||||
|
const commandUser = interaction.guild.members.cache.get(
|
||||||
|
interaction.user.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
await interaction.deferReply({
|
||||||
|
flags: ['Ephemeral'],
|
||||||
|
});
|
||||||
|
|
||||||
|
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.editReply({
|
||||||
|
content: 'You do not have permission to use this command',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
459
src/db/db.ts
|
@ -1,20 +1,47 @@
|
||||||
|
// ========================
|
||||||
|
// External Imports
|
||||||
|
// ========================
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
import pkg from 'pg';
|
import pkg from 'pg';
|
||||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||||
import { eq } from 'drizzle-orm';
|
import { Client } from 'discord.js';
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// Internal Imports
|
||||||
|
// ========================
|
||||||
import * as schema from './schema.js';
|
import * as schema from './schema.js';
|
||||||
import { loadConfig } from '../util/configLoader.js';
|
import { loadConfig } from '@/util/configLoader.js';
|
||||||
import { del, exists, getJson, setJson } from './redis.js';
|
import { del, exists, getJson, setJson } from './redis.js';
|
||||||
|
import {
|
||||||
|
logManagerNotification,
|
||||||
|
NotificationType,
|
||||||
|
notifyManagers,
|
||||||
|
} from '@/util/notificationHandler.js';
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// Database Configuration
|
||||||
|
// ========================
|
||||||
const { Pool } = pkg;
|
const { Pool } = pkg;
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
|
|
||||||
const dbPool = new Pool({
|
// Connection parameters
|
||||||
connectionString: config.dbConnectionString,
|
const MAX_DB_RETRY_ATTEMPTS = config.database.maxRetryAttempts;
|
||||||
ssl: true,
|
const INITIAL_DB_RETRY_DELAY = config.database.retryDelay;
|
||||||
});
|
|
||||||
export const db = drizzle({ client: dbPool, schema });
|
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// Connection State Variables
|
||||||
|
// ========================
|
||||||
|
let isDbConnected = false;
|
||||||
|
let connectionAttempts = 0;
|
||||||
|
let hasNotifiedDbDisconnect = false;
|
||||||
|
let discordClient: Client | null = null;
|
||||||
|
let dbPool: pkg.Pool;
|
||||||
|
export let db: ReturnType<typeof drizzle>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom error class for database operations
|
||||||
|
*/
|
||||||
class DatabaseError extends Error {
|
class DatabaseError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
|
@ -25,208 +52,278 @@ class DatabaseError extends Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAllMembers() {
|
// ========================
|
||||||
|
// Client Management
|
||||||
|
// ========================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the Discord client for sending notifications
|
||||||
|
* @param client - The Discord client
|
||||||
|
*/
|
||||||
|
export function setDiscordClient(client: Client): void {
|
||||||
|
discordClient = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// Connection Management
|
||||||
|
// ========================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the database connection with retry logic
|
||||||
|
* @returns Promise resolving to true if connected successfully, false otherwise
|
||||||
|
*/
|
||||||
|
export async function initializeDatabaseConnection(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
if (await exists('nonBotMembers')) {
|
// Check if existing connection is working
|
||||||
const memberData =
|
if (dbPool) {
|
||||||
await getJson<(typeof schema.memberTable.$inferSelect)[]>(
|
try {
|
||||||
'nonBotMembers',
|
await dbPool.query('SELECT 1');
|
||||||
|
isDbConnected = true;
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
'Existing database connection is not responsive, creating a new one',
|
||||||
);
|
);
|
||||||
if (memberData && memberData.length > 0) {
|
try {
|
||||||
return memberData;
|
await dbPool.end();
|
||||||
} else {
|
} catch (endError) {
|
||||||
await del('nonBotMembers');
|
console.error('Error ending pool:', endError);
|
||||||
return await getAllMembers();
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
const nonBotMembers = await db
|
|
||||||
.select()
|
|
||||||
.from(schema.memberTable)
|
|
||||||
.where(eq(schema.memberTable.currentlyInServer, true));
|
|
||||||
await setJson<(typeof schema.memberTable.$inferSelect)[]>(
|
|
||||||
'nonBotMembers',
|
|
||||||
nonBotMembers,
|
|
||||||
);
|
|
||||||
return nonBotMembers;
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('Error getting all members: ', error);
|
|
||||||
throw new DatabaseError('Failed to get all members: ', error as Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setMembers(nonBotMembers: any) {
|
// Log the database connection attempt
|
||||||
try {
|
console.log(
|
||||||
nonBotMembers.forEach(async (member: any) => {
|
`Connecting to database... (connectionString length: ${config.database.dbConnectionString.length})`,
|
||||||
const memberInfo = await db
|
);
|
||||||
.select()
|
|
||||||
.from(schema.memberTable)
|
// Create new connection pool
|
||||||
.where(eq(schema.memberTable.discordId, member.user.id));
|
dbPool = new Pool({
|
||||||
if (memberInfo.length > 0) {
|
connectionString: config.database.dbConnectionString,
|
||||||
await updateMember({
|
ssl: (() => {
|
||||||
discordId: member.user.id,
|
try {
|
||||||
discordUsername: member.user.username,
|
return {
|
||||||
currentlyInServer: true,
|
ca: fs.readFileSync(path.resolve('./certs/psql-ca.crt')),
|
||||||
});
|
key: fs.readFileSync(path.resolve('./certs/psql-client.key')),
|
||||||
} else {
|
cert: fs.readFileSync(path.resolve('./certs/psql-server.crt')),
|
||||||
const members: typeof schema.memberTable.$inferInsert = {
|
};
|
||||||
discordId: member.user.id,
|
} catch (error) {
|
||||||
discordUsername: member.user.username,
|
console.warn(
|
||||||
};
|
'Failed to load certificates for database, using insecure connection:',
|
||||||
await db.insert(schema.memberTable).values(members);
|
error,
|
||||||
}
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
connectionTimeoutMillis: 10000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Test connection
|
||||||
|
await dbPool.query('SELECT 1');
|
||||||
|
|
||||||
|
// Initialize Drizzle ORM
|
||||||
|
db = drizzle({ client: dbPool, schema });
|
||||||
|
|
||||||
|
// Connection successful
|
||||||
|
console.info('Successfully connected to database');
|
||||||
|
isDbConnected = true;
|
||||||
|
connectionAttempts = 0;
|
||||||
|
|
||||||
|
// Send notification if connection was previously lost
|
||||||
|
if (hasNotifiedDbDisconnect && discordClient) {
|
||||||
|
logManagerNotification(NotificationType.DATABASE_CONNECTION_RESTORED);
|
||||||
|
notifyManagers(
|
||||||
|
discordClient,
|
||||||
|
NotificationType.DATABASE_CONNECTION_RESTORED,
|
||||||
|
);
|
||||||
|
hasNotifiedDbDisconnect = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error setting members: ', error);
|
console.error('Failed to connect to database:', error);
|
||||||
throw new DatabaseError('Failed to set members: ', error as Error);
|
isDbConnected = false;
|
||||||
}
|
connectionAttempts++;
|
||||||
}
|
|
||||||
|
|
||||||
export async function getMember(discordId: string) {
|
// Handle max retry attempts exceeded
|
||||||
try {
|
if (connectionAttempts >= MAX_DB_RETRY_ATTEMPTS) {
|
||||||
if (await exists(`${discordId}-memberInfo`)) {
|
if (!hasNotifiedDbDisconnect && discordClient) {
|
||||||
const cachedMember = await getJson<
|
const message = `Failed to connect to database after ${connectionAttempts} attempts.`;
|
||||||
typeof schema.memberTable.$inferSelect
|
console.error(message);
|
||||||
>(`${discordId}-memberInfo`);
|
logManagerNotification(
|
||||||
const cachedModerationHistory = await getJson<
|
NotificationType.DATABASE_CONNECTION_LOST,
|
||||||
(typeof schema.moderationTable.$inferSelect)[]
|
`Error: ${error}`,
|
||||||
>(`${discordId}-moderationHistory`);
|
);
|
||||||
|
notifyManagers(
|
||||||
if (
|
discordClient,
|
||||||
cachedMember &&
|
NotificationType.DATABASE_CONNECTION_LOST,
|
||||||
'discordId' in cachedMember &&
|
`Connection attempts exhausted after ${connectionAttempts} tries. The bot cannot function without database access and will now terminate.`,
|
||||||
cachedModerationHistory &&
|
);
|
||||||
cachedModerationHistory.length > 0
|
hasNotifiedDbDisconnect = true;
|
||||||
) {
|
|
||||||
return {
|
|
||||||
...cachedMember,
|
|
||||||
moderations: cachedModerationHistory,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
await del(`${discordId}-memberInfo`);
|
|
||||||
await del(`${discordId}-moderationHistory`);
|
|
||||||
return await getMember(discordId);
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
const member = await db.query.memberTable.findFirst({
|
|
||||||
where: eq(schema.memberTable.discordId, discordId),
|
|
||||||
with: {
|
|
||||||
moderations: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await setJson<typeof schema.memberTable.$inferSelect>(
|
// Terminate after sending notifications
|
||||||
`${discordId}-memberInfo`,
|
setTimeout(() => {
|
||||||
member!,
|
console.error('Database connection failed, shutting down bot');
|
||||||
);
|
process.exit(1);
|
||||||
await setJson<(typeof schema.moderationTable.$inferSelect)[]>(
|
}, 3000);
|
||||||
`${discordId}-moderationHistory`,
|
|
||||||
member!.moderations,
|
|
||||||
);
|
|
||||||
|
|
||||||
return member;
|
return false;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('Error getting member: ', error);
|
// Retry connection with exponential backoff
|
||||||
throw new DatabaseError('Failed to get member: ', error as Error);
|
const delay = Math.min(
|
||||||
|
INITIAL_DB_RETRY_DELAY * Math.pow(2, connectionAttempts - 1),
|
||||||
|
30000,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`Retrying database connection in ${delay}ms... (Attempt ${connectionAttempts}/${MAX_DB_RETRY_ATTEMPTS})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
setTimeout(initializeDatabaseConnection, delay);
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateMember({
|
// Initialize database connection
|
||||||
discordId,
|
let dbInitPromise = initializeDatabaseConnection().catch((error) => {
|
||||||
discordUsername,
|
console.error('Failed to initialize database connection:', error);
|
||||||
currentlyInServer,
|
process.exit(1);
|
||||||
currentlyBanned,
|
});
|
||||||
}: schema.memberTableTypes) {
|
|
||||||
try {
|
|
||||||
const result = await db
|
|
||||||
.update(schema.memberTable)
|
|
||||||
.set({
|
|
||||||
discordUsername,
|
|
||||||
currentlyInServer,
|
|
||||||
currentlyBanned,
|
|
||||||
})
|
|
||||||
.where(eq(schema.memberTable.discordId, discordId));
|
|
||||||
|
|
||||||
if (await exists(`${discordId}-memberInfo`)) {
|
// ========================
|
||||||
await del(`${discordId}-memberInfo`);
|
// Helper Functions
|
||||||
}
|
// ========================
|
||||||
if (await exists('nonBotMembers')) {
|
|
||||||
await del('nonBotMembers');
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
/**
|
||||||
} catch (error) {
|
* Ensures the database is initialized and returns a promise
|
||||||
console.error('Error updating member: ', error);
|
* @returns Promise for database initialization
|
||||||
throw new DatabaseError('Failed to update member: ', error as Error);
|
*/
|
||||||
|
export async function ensureDbInitialized(): Promise<void> {
|
||||||
|
await dbInitPromise;
|
||||||
|
|
||||||
|
if (!isDbConnected) {
|
||||||
|
dbInitPromise = initializeDatabaseConnection();
|
||||||
|
await dbInitPromise;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateMemberModerationHistory({
|
/**
|
||||||
discordId,
|
* Checks if the database connection is active and working
|
||||||
moderatorDiscordId,
|
* @returns Promise resolving to true if connected, false otherwise
|
||||||
action,
|
*/
|
||||||
reason,
|
export async function ensureDatabaseConnection(): Promise<boolean> {
|
||||||
duration,
|
await ensureDbInitialized();
|
||||||
createdAt,
|
|
||||||
expiresAt,
|
if (!isDbConnected) {
|
||||||
active,
|
return await initializeDatabaseConnection();
|
||||||
}: schema.moderationTableTypes) {
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const moderationEntry = {
|
await dbPool.query('SELECT 1');
|
||||||
discordId,
|
return true;
|
||||||
moderatorDiscordId,
|
|
||||||
action,
|
|
||||||
reason,
|
|
||||||
duration,
|
|
||||||
createdAt,
|
|
||||||
expiresAt,
|
|
||||||
active,
|
|
||||||
};
|
|
||||||
const result = await db
|
|
||||||
.insert(schema.moderationTable)
|
|
||||||
.values(moderationEntry);
|
|
||||||
|
|
||||||
if (await exists(`${discordId}-moderationHistory`)) {
|
|
||||||
await del(`${discordId}-moderationHistory`);
|
|
||||||
}
|
|
||||||
if (await exists(`${discordId}-memberInfo`)) {
|
|
||||||
await del(`${discordId}-memberInfo`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating moderation history: ', error);
|
console.error('Database connection test failed:', error);
|
||||||
throw new DatabaseError(
|
isDbConnected = false;
|
||||||
'Failed to update moderation history: ',
|
return await initializeDatabaseConnection();
|
||||||
error as Error,
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic error handler for database operations
|
||||||
|
* @param errorMessage - Error message to log
|
||||||
|
* @param error - Original error object
|
||||||
|
* @throws {DatabaseError} - Always throws a wrapped database error
|
||||||
|
*/
|
||||||
|
export const handleDbError = (errorMessage: string, error: Error): never => {
|
||||||
|
console.error(`${errorMessage}:`, error);
|
||||||
|
|
||||||
|
// Check if error is related to connection and attempt to reconnect
|
||||||
|
if (
|
||||||
|
error.message.includes('connection') ||
|
||||||
|
error.message.includes('connect')
|
||||||
|
) {
|
||||||
|
isDbConnected = false;
|
||||||
|
ensureDatabaseConnection().catch((err) => {
|
||||||
|
console.error('Failed to reconnect to database:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new DatabaseError(errorMessage, error);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// Cache Management
|
||||||
|
// ========================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks and retrieves cached data or fetches from database
|
||||||
|
* @param cacheKey - Key to check in cache
|
||||||
|
* @param dbFetch - Function to fetch data from database
|
||||||
|
* @param ttl - Time to live for cache in seconds
|
||||||
|
* @returns Cached or freshly fetched data
|
||||||
|
*/
|
||||||
|
export async function withCache<T>(
|
||||||
|
cacheKey: string,
|
||||||
|
dbFetch: () => Promise<T>,
|
||||||
|
ttl?: number,
|
||||||
|
): Promise<T> {
|
||||||
|
try {
|
||||||
|
const cachedData = await getJson<T>(cacheKey);
|
||||||
|
if (cachedData !== null) {
|
||||||
|
return cachedData;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
`Cache retrieval failed for ${cacheKey}, falling back to database:`,
|
||||||
|
error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const data = await dbFetch();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await setJson(cacheKey, data, ttl);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to cache data for ${cacheKey}:`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMemberModerationHistory(discordId: string) {
|
/**
|
||||||
|
* Invalidates a cache key if it exists
|
||||||
|
* @param cacheKey - Key to invalidate
|
||||||
|
*/
|
||||||
|
export async function invalidateCache(cacheKey: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (await exists(`${discordId}-moderationHistory`)) {
|
if (await exists(cacheKey)) {
|
||||||
return await getJson<(typeof schema.moderationTable.$inferSelect)[]>(
|
await del(cacheKey);
|
||||||
`${discordId}-moderationHistory`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const moderationHistory = await db
|
|
||||||
.select()
|
|
||||||
.from(schema.moderationTable)
|
|
||||||
.where(eq(schema.moderationTable.discordId, discordId));
|
|
||||||
|
|
||||||
await setJson<(typeof schema.moderationTable.$inferSelect)[]>(
|
|
||||||
`${discordId}-moderationHistory`,
|
|
||||||
moderationHistory,
|
|
||||||
);
|
|
||||||
return moderationHistory;
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting moderation history: ', error);
|
console.warn(`Error invalidating cache for key ${cacheKey}:`, error);
|
||||||
throw new DatabaseError(
|
|
||||||
'Failed to get moderation history: ',
|
|
||||||
error as Error,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// Database Functions Exports
|
||||||
|
// ========================
|
||||||
|
|
||||||
|
// Achievement related functions
|
||||||
|
export * from './functions/achievementFunctions.js';
|
||||||
|
|
||||||
|
// Facts system functions
|
||||||
|
export * from './functions/factFunctions.js';
|
||||||
|
|
||||||
|
// Giveaway management functions
|
||||||
|
export * from './functions/giveawayFunctions.js';
|
||||||
|
|
||||||
|
// User leveling system functions
|
||||||
|
export * from './functions/levelFunctions.js';
|
||||||
|
|
||||||
|
// Guild member management functions
|
||||||
|
export * from './functions/memberFunctions.js';
|
||||||
|
|
||||||
|
// Moderation and administration functions
|
||||||
|
export * from './functions/moderationFunctions.js';
|
||||||
|
|
282
src/db/functions/achievementFunctions.ts
Normal file
|
@ -0,0 +1,282 @@
|
||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db, ensureDbInitialized, handleDbError } from '../db.js';
|
||||||
|
import * as schema from '../schema.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all achievement definitions
|
||||||
|
* @returns Array of achievement definitions
|
||||||
|
*/
|
||||||
|
export async function getAllAchievements(): Promise<
|
||||||
|
schema.achievementDefinitionsTableTypes[]
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot get achievements');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return await db
|
||||||
|
.select()
|
||||||
|
.from(schema.achievementDefinitionsTable)
|
||||||
|
.orderBy(schema.achievementDefinitionsTable.threshold);
|
||||||
|
} catch (error) {
|
||||||
|
return handleDbError('Failed to get all achievements', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get achievements for a specific user
|
||||||
|
* @param userId - Discord ID of the user
|
||||||
|
* @returns Array of user achievements
|
||||||
|
*/
|
||||||
|
export async function getUserAchievements(
|
||||||
|
userId: string,
|
||||||
|
): Promise<schema.userAchievementsTableTypes[]> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot get user achievements');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return await db
|
||||||
|
.select({
|
||||||
|
id: schema.userAchievementsTable.id,
|
||||||
|
discordId: schema.userAchievementsTable.discordId,
|
||||||
|
achievementId: schema.userAchievementsTable.achievementId,
|
||||||
|
earnedAt: schema.userAchievementsTable.earnedAt,
|
||||||
|
progress: schema.userAchievementsTable.progress,
|
||||||
|
})
|
||||||
|
.from(schema.userAchievementsTable)
|
||||||
|
.where(eq(schema.userAchievementsTable.discordId, userId));
|
||||||
|
} catch (error) {
|
||||||
|
return handleDbError('Failed to get user achievements', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Award an achievement to a user
|
||||||
|
* @param userId - Discord ID of the user
|
||||||
|
* @param achievementId - ID of the achievement
|
||||||
|
* @returns Boolean indicating success
|
||||||
|
*/
|
||||||
|
export async function awardAchievement(
|
||||||
|
userId: string,
|
||||||
|
achievementId: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot award achievement');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.userAchievementsTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.userAchievementsTable.discordId, userId),
|
||||||
|
eq(schema.userAchievementsTable.achievementId, achievementId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.then((rows) => rows[0]);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (existing.earnedAt) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(schema.userAchievementsTable)
|
||||||
|
.set({
|
||||||
|
earnedAt: new Date(),
|
||||||
|
progress: 100,
|
||||||
|
})
|
||||||
|
.where(eq(schema.userAchievementsTable.id, existing.id));
|
||||||
|
} else {
|
||||||
|
await db.insert(schema.userAchievementsTable).values({
|
||||||
|
discordId: userId,
|
||||||
|
achievementId: achievementId,
|
||||||
|
earnedAt: new Date(),
|
||||||
|
progress: 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
handleDbError('Failed to award achievement', error as Error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update achievement progress for a user
|
||||||
|
* @param userId - Discord ID of the user
|
||||||
|
* @param achievementId - ID of the achievement
|
||||||
|
* @param progress - Progress value (0-100)
|
||||||
|
* @returns Boolean indicating success
|
||||||
|
*/
|
||||||
|
export async function updateAchievementProgress(
|
||||||
|
userId: string,
|
||||||
|
achievementId: number,
|
||||||
|
progress: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error(
|
||||||
|
'Database not initialized, cannot update achievement progress',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.userAchievementsTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.userAchievementsTable.discordId, userId),
|
||||||
|
eq(schema.userAchievementsTable.achievementId, achievementId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.then((rows) => rows[0]);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (existing.earnedAt) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(schema.userAchievementsTable)
|
||||||
|
.set({
|
||||||
|
progress: Math.floor(progress) > 100 ? 100 : Math.floor(progress),
|
||||||
|
})
|
||||||
|
.where(eq(schema.userAchievementsTable.id, existing.id));
|
||||||
|
} else {
|
||||||
|
await db.insert(schema.userAchievementsTable).values({
|
||||||
|
discordId: userId,
|
||||||
|
achievementId: achievementId,
|
||||||
|
progress: Math.floor(progress) > 100 ? 100 : Math.floor(progress),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
handleDbError('Failed to update achievement progress', error as Error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new achievement definition
|
||||||
|
* @param achievementData - Achievement definition data
|
||||||
|
* @returns Created achievement or undefined on failure
|
||||||
|
*/
|
||||||
|
export async function createAchievement(achievementData: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
requirementType: string;
|
||||||
|
threshold: number;
|
||||||
|
requirement?: any;
|
||||||
|
rewardType?: string;
|
||||||
|
rewardValue?: string;
|
||||||
|
}): Promise<schema.achievementDefinitionsTableTypes | undefined> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot create achievement');
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [achievement] = await db
|
||||||
|
.insert(schema.achievementDefinitionsTable)
|
||||||
|
.values({
|
||||||
|
name: achievementData.name,
|
||||||
|
description: achievementData.description,
|
||||||
|
imageUrl: achievementData.imageUrl || null,
|
||||||
|
requirementType: achievementData.requirementType,
|
||||||
|
threshold: achievementData.threshold,
|
||||||
|
requirement: achievementData.requirement || {},
|
||||||
|
rewardType: achievementData.rewardType || null,
|
||||||
|
rewardValue: achievementData.rewardValue || null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return achievement;
|
||||||
|
} catch (error) {
|
||||||
|
return handleDbError('Failed to create achievement', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an achievement definition
|
||||||
|
* @param achievementId - ID of the achievement to delete
|
||||||
|
* @returns Boolean indicating success
|
||||||
|
*/
|
||||||
|
export async function deleteAchievement(
|
||||||
|
achievementId: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot delete achievement');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(schema.userAchievementsTable)
|
||||||
|
.where(eq(schema.userAchievementsTable.achievementId, achievementId));
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(schema.achievementDefinitionsTable)
|
||||||
|
.where(eq(schema.achievementDefinitionsTable.id, achievementId));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
handleDbError('Failed to delete achievement', error as Error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes an achievement from a user
|
||||||
|
* @param discordId - Discord user ID
|
||||||
|
* @param achievementId - Achievement ID to remove
|
||||||
|
* @returns boolean indicating success
|
||||||
|
*/
|
||||||
|
export async function removeUserAchievement(
|
||||||
|
discordId: string,
|
||||||
|
achievementId: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot remove user achievement');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(schema.userAchievementsTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.userAchievementsTable.discordId, discordId),
|
||||||
|
eq(schema.userAchievementsTable.achievementId, achievementId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
handleDbError('Failed to remove user achievement', error as Error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
198
src/db/functions/factFunctions.ts
Normal file
|
@ -0,0 +1,198 @@
|
||||||
|
import { and, eq, isNull, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import {
|
||||||
|
db,
|
||||||
|
ensureDbInitialized,
|
||||||
|
handleDbError,
|
||||||
|
invalidateCache,
|
||||||
|
withCache,
|
||||||
|
} from '../db.js';
|
||||||
|
import * as schema from '../schema.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new fact to the database
|
||||||
|
* @param content - Content of the fact
|
||||||
|
* @param source - Source of the fact
|
||||||
|
* @param addedBy - Discord ID of the user who added the fact
|
||||||
|
* @param approved - Whether the fact is approved or not
|
||||||
|
*/
|
||||||
|
export async function addFact({
|
||||||
|
content,
|
||||||
|
source,
|
||||||
|
addedBy,
|
||||||
|
approved = false,
|
||||||
|
}: schema.factTableTypes): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot add fact');
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(schema.factTable).values({
|
||||||
|
content,
|
||||||
|
source,
|
||||||
|
addedBy,
|
||||||
|
approved,
|
||||||
|
});
|
||||||
|
|
||||||
|
await invalidateCache('unused-facts');
|
||||||
|
} catch (error) {
|
||||||
|
handleDbError('Failed to add fact', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the ID of the most recently added fact
|
||||||
|
* @returns ID of the last inserted fact
|
||||||
|
*/
|
||||||
|
export async function getLastInsertedFactId(): Promise<number> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot get last inserted fact');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.select({ id: sql<number>`MAX(${schema.factTable.id})` })
|
||||||
|
.from(schema.factTable);
|
||||||
|
|
||||||
|
return result[0]?.id ?? 0;
|
||||||
|
} catch (error) {
|
||||||
|
return handleDbError('Failed to get last inserted fact ID', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a random fact that hasn't been used yet
|
||||||
|
* @returns Random fact object
|
||||||
|
*/
|
||||||
|
export async function getRandomUnusedFact(): Promise<schema.factTableTypes> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot get random unused fact');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = 'unused-facts';
|
||||||
|
const facts = await withCache<schema.factTableTypes[]>(
|
||||||
|
cacheKey,
|
||||||
|
async () => {
|
||||||
|
return (await db
|
||||||
|
.select()
|
||||||
|
.from(schema.factTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.factTable.approved, true),
|
||||||
|
isNull(schema.factTable.usedOn),
|
||||||
|
),
|
||||||
|
)) as schema.factTableTypes[];
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (facts.length === 0) {
|
||||||
|
await db
|
||||||
|
.update(schema.factTable)
|
||||||
|
.set({ usedOn: null })
|
||||||
|
.where(eq(schema.factTable.approved, true));
|
||||||
|
|
||||||
|
await invalidateCache(cacheKey);
|
||||||
|
return await getRandomUnusedFact();
|
||||||
|
}
|
||||||
|
|
||||||
|
return facts[
|
||||||
|
Math.floor(Math.random() * facts.length)
|
||||||
|
] as schema.factTableTypes;
|
||||||
|
} catch (error) {
|
||||||
|
return handleDbError('Failed to get random fact', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a fact as used
|
||||||
|
* @param id - ID of the fact to mark as used
|
||||||
|
*/
|
||||||
|
export async function markFactAsUsed(id: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot mark fact as used');
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(schema.factTable)
|
||||||
|
.set({ usedOn: new Date() })
|
||||||
|
.where(eq(schema.factTable.id, id));
|
||||||
|
|
||||||
|
await invalidateCache('unused-facts');
|
||||||
|
} catch (error) {
|
||||||
|
handleDbError('Failed to mark fact as used', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all pending facts that need approval
|
||||||
|
* @returns Array of pending fact objects
|
||||||
|
*/
|
||||||
|
export async function getPendingFacts(): Promise<schema.factTableTypes[]> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot get pending facts');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await db
|
||||||
|
.select()
|
||||||
|
.from(schema.factTable)
|
||||||
|
.where(eq(schema.factTable.approved, false))) as schema.factTableTypes[];
|
||||||
|
} catch (error) {
|
||||||
|
return handleDbError('Failed to get pending facts', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approve a fact
|
||||||
|
* @param id - ID of the fact to approve
|
||||||
|
*/
|
||||||
|
export async function approveFact(id: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot approve fact');
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(schema.factTable)
|
||||||
|
.set({ approved: true })
|
||||||
|
.where(eq(schema.factTable.id, id));
|
||||||
|
|
||||||
|
await invalidateCache('unused-facts');
|
||||||
|
} catch (error) {
|
||||||
|
handleDbError('Failed to approve fact', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a fact
|
||||||
|
* @param id - ID of the fact to delete
|
||||||
|
*/
|
||||||
|
export async function deleteFact(id: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot delete fact');
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(schema.factTable).where(eq(schema.factTable.id, id));
|
||||||
|
|
||||||
|
await invalidateCache('unused-facts');
|
||||||
|
} catch (error) {
|
||||||
|
return handleDbError('Failed to delete fact', error as Error);
|
||||||
|
}
|
||||||
|
}
|
275
src/db/functions/giveawayFunctions.ts
Normal file
|
@ -0,0 +1,275 @@
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db, ensureDbInitialized, handleDbError } from '../db.js';
|
||||||
|
import { selectGiveawayWinners } from '@/util/giveaways/utils.js';
|
||||||
|
import * as schema from '../schema.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a giveaway in the database
|
||||||
|
* @param giveawayData - Data for the giveaway
|
||||||
|
* @returns Created giveaway object
|
||||||
|
*/
|
||||||
|
export async function createGiveaway(giveawayData: {
|
||||||
|
channelId: string;
|
||||||
|
messageId: string;
|
||||||
|
endAt: Date;
|
||||||
|
prize: string;
|
||||||
|
winnerCount: number;
|
||||||
|
hostId: string;
|
||||||
|
requirements?: {
|
||||||
|
level?: number;
|
||||||
|
roleId?: string;
|
||||||
|
messageCount?: number;
|
||||||
|
requireAll?: boolean;
|
||||||
|
};
|
||||||
|
bonuses?: {
|
||||||
|
roles?: Array<{ id: string; entries: number }>;
|
||||||
|
levels?: Array<{ threshold: number; entries: number }>;
|
||||||
|
messages?: Array<{ threshold: number; entries: number }>;
|
||||||
|
};
|
||||||
|
}): Promise<schema.giveawayTableTypes> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot create giveaway');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [giveaway] = await db
|
||||||
|
.insert(schema.giveawayTable)
|
||||||
|
.values({
|
||||||
|
channelId: giveawayData.channelId,
|
||||||
|
messageId: giveawayData.messageId,
|
||||||
|
endAt: giveawayData.endAt,
|
||||||
|
prize: giveawayData.prize,
|
||||||
|
winnerCount: giveawayData.winnerCount,
|
||||||
|
hostId: giveawayData.hostId,
|
||||||
|
requiredLevel: giveawayData.requirements?.level,
|
||||||
|
requiredRoleId: giveawayData.requirements?.roleId,
|
||||||
|
requiredMessageCount: giveawayData.requirements?.messageCount,
|
||||||
|
requireAllCriteria: giveawayData.requirements?.requireAll ?? true,
|
||||||
|
bonusEntries:
|
||||||
|
giveawayData.bonuses as schema.giveawayTableTypes['bonusEntries'],
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return giveaway as schema.giveawayTableTypes;
|
||||||
|
} catch (error) {
|
||||||
|
return handleDbError('Failed to create giveaway', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a giveaway by ID or message ID
|
||||||
|
* @param id - ID of the giveaway
|
||||||
|
* @param isDbId - Whether the ID is a database ID
|
||||||
|
* @returns Giveaway object or undefined if not found
|
||||||
|
*/
|
||||||
|
export async function getGiveaway(
|
||||||
|
id: string | number,
|
||||||
|
isDbId = false,
|
||||||
|
): Promise<schema.giveawayTableTypes | undefined> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot get giveaway');
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDbId) {
|
||||||
|
const numId = typeof id === 'string' ? parseInt(id) : id;
|
||||||
|
const [giveaway] = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.giveawayTable)
|
||||||
|
.where(eq(schema.giveawayTable.id, numId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return giveaway as schema.giveawayTableTypes;
|
||||||
|
} else {
|
||||||
|
const [giveaway] = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.giveawayTable)
|
||||||
|
.where(eq(schema.giveawayTable.messageId, id as string))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return giveaway as schema.giveawayTableTypes;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return handleDbError('Failed to get giveaway', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active giveaways
|
||||||
|
* @returns Array of active giveaway objects
|
||||||
|
*/
|
||||||
|
export async function getActiveGiveaways(): Promise<
|
||||||
|
schema.giveawayTableTypes[]
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot get active giveaways');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await db
|
||||||
|
.select()
|
||||||
|
.from(schema.giveawayTable)
|
||||||
|
.where(
|
||||||
|
eq(schema.giveawayTable.status, 'active'),
|
||||||
|
)) as schema.giveawayTableTypes[];
|
||||||
|
} catch (error) {
|
||||||
|
return handleDbError('Failed to get active giveaways', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update giveaway participants
|
||||||
|
* @param messageId - ID of the giveaway message
|
||||||
|
* @param userId - ID of the user to add
|
||||||
|
* @param entries - Number of entries to add
|
||||||
|
* @return 'success' | 'already_entered' | 'inactive' | 'error'
|
||||||
|
*/
|
||||||
|
export async function addGiveawayParticipant(
|
||||||
|
messageId: string,
|
||||||
|
userId: string,
|
||||||
|
entries = 1,
|
||||||
|
): Promise<'success' | 'already_entered' | 'inactive' | 'error'> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot add participant');
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
const giveaway = await getGiveaway(messageId);
|
||||||
|
if (!giveaway || giveaway.status !== 'active') {
|
||||||
|
return 'inactive';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (giveaway.participants?.includes(userId)) {
|
||||||
|
return 'already_entered';
|
||||||
|
}
|
||||||
|
|
||||||
|
const participants = [...(giveaway.participants || [])];
|
||||||
|
for (let i = 0; i < entries; i++) {
|
||||||
|
participants.push(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(schema.giveawayTable)
|
||||||
|
.set({ participants: participants })
|
||||||
|
.where(eq(schema.giveawayTable.messageId, messageId));
|
||||||
|
|
||||||
|
return 'success';
|
||||||
|
} catch (error) {
|
||||||
|
handleDbError('Failed to add giveaway participant', error as Error);
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End a giveaway
|
||||||
|
* @param id - ID of the giveaway
|
||||||
|
* @param isDbId - Whether the ID is a database ID
|
||||||
|
* @param forceWinners - Array of user IDs to force as winners
|
||||||
|
* @return Updated giveaway object
|
||||||
|
*/
|
||||||
|
export async function endGiveaway(
|
||||||
|
id: string | number,
|
||||||
|
isDbId = false,
|
||||||
|
forceWinners?: string[],
|
||||||
|
): Promise<schema.giveawayTableTypes | undefined> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot end giveaway');
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const giveaway = await getGiveaway(id, isDbId);
|
||||||
|
if (!giveaway || giveaway.status !== 'active' || !giveaway.participants) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const winners = selectGiveawayWinners(
|
||||||
|
giveaway.participants,
|
||||||
|
giveaway.winnerCount,
|
||||||
|
forceWinners,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [updatedGiveaway] = await db
|
||||||
|
.update(schema.giveawayTable)
|
||||||
|
.set({
|
||||||
|
status: 'ended',
|
||||||
|
winnersIds: winners,
|
||||||
|
})
|
||||||
|
.where(eq(schema.giveawayTable.id, giveaway.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updatedGiveaway as schema.giveawayTableTypes;
|
||||||
|
} catch (error) {
|
||||||
|
return handleDbError('Failed to end giveaway', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reroll winners for a giveaway
|
||||||
|
* @param id - ID of the giveaway
|
||||||
|
* @return Updated giveaway object
|
||||||
|
*/
|
||||||
|
export async function rerollGiveaway(
|
||||||
|
id: string,
|
||||||
|
): Promise<schema.giveawayTableTypes | undefined> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot reroll giveaway');
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const giveaway = await getGiveaway(id, true);
|
||||||
|
if (
|
||||||
|
!giveaway ||
|
||||||
|
!giveaway.participants ||
|
||||||
|
giveaway.participants.length === 0 ||
|
||||||
|
giveaway.status !== 'ended'
|
||||||
|
) {
|
||||||
|
console.warn(
|
||||||
|
`Cannot reroll giveaway ${id}: Not found, no participants, or not ended.`,
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newWinners = selectGiveawayWinners(
|
||||||
|
giveaway.participants,
|
||||||
|
giveaway.winnerCount,
|
||||||
|
undefined,
|
||||||
|
giveaway.winnersIds ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newWinners.length === 0) {
|
||||||
|
console.warn(
|
||||||
|
`Cannot reroll giveaway ${id}: No eligible participants left after excluding previous winners.`,
|
||||||
|
);
|
||||||
|
return giveaway;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updatedGiveaway] = await db
|
||||||
|
.update(schema.giveawayTable)
|
||||||
|
.set({
|
||||||
|
winnersIds: newWinners,
|
||||||
|
})
|
||||||
|
.where(eq(schema.giveawayTable.id, giveaway.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updatedGiveaway as schema.giveawayTableTypes;
|
||||||
|
} catch (error) {
|
||||||
|
return handleDbError('Failed to reroll giveaway', error as Error);
|
||||||
|
}
|
||||||
|
}
|
329
src/db/functions/levelFunctions.ts
Normal file
|
@ -0,0 +1,329 @@
|
||||||
|
import { desc, eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import {
|
||||||
|
db,
|
||||||
|
ensureDbInitialized,
|
||||||
|
handleDbError,
|
||||||
|
invalidateCache,
|
||||||
|
withCache,
|
||||||
|
} from '../db.js';
|
||||||
|
import * as schema from '../schema.js';
|
||||||
|
import { calculateLevelFromXp } from '@/util/levelingSystem.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user level information or create a new entry if not found
|
||||||
|
* @param discordId - Discord ID of the user
|
||||||
|
* @returns User level object
|
||||||
|
*/
|
||||||
|
export async function getUserLevel(
|
||||||
|
discordId: string,
|
||||||
|
): Promise<schema.levelTableTypes> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot get user level');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `level-${discordId}`;
|
||||||
|
|
||||||
|
return await withCache<schema.levelTableTypes>(
|
||||||
|
cacheKey,
|
||||||
|
async () => {
|
||||||
|
const level = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.levelTable)
|
||||||
|
.where(eq(schema.levelTable.discordId, discordId))
|
||||||
|
.then((rows) => rows[0]);
|
||||||
|
|
||||||
|
if (level) {
|
||||||
|
return {
|
||||||
|
...level,
|
||||||
|
lastMessageTimestamp: level.lastMessageTimestamp ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const newLevel: schema.levelTableTypes = {
|
||||||
|
discordId,
|
||||||
|
xp: 0,
|
||||||
|
level: 0,
|
||||||
|
lastMessageTimestamp: new Date(),
|
||||||
|
messagesSent: 0,
|
||||||
|
reactionCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.insert(schema.levelTable).values(newLevel);
|
||||||
|
return newLevel;
|
||||||
|
},
|
||||||
|
300,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return handleDbError('Error getting user level', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add XP to a user, updating their level if necessary
|
||||||
|
* @param discordId - Discord ID of the user
|
||||||
|
* @param amount - Amount of XP to add
|
||||||
|
*/
|
||||||
|
export async function addXpToUser(
|
||||||
|
discordId: string,
|
||||||
|
amount: number,
|
||||||
|
): Promise<{
|
||||||
|
leveledUp: boolean;
|
||||||
|
newLevel: number;
|
||||||
|
oldLevel: number;
|
||||||
|
messagesSent: number;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot add xp to user');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `level-${discordId}`;
|
||||||
|
const userData = await getUserLevel(discordId);
|
||||||
|
const currentLevel = userData.level;
|
||||||
|
const currentXp = Number(userData.xp);
|
||||||
|
const xpToAdd = Number(amount);
|
||||||
|
|
||||||
|
userData.xp = currentXp + xpToAdd;
|
||||||
|
|
||||||
|
userData.lastMessageTimestamp = new Date();
|
||||||
|
userData.level = calculateLevelFromXp(userData.xp);
|
||||||
|
userData.messagesSent += 1;
|
||||||
|
|
||||||
|
await invalidateLeaderboardCache();
|
||||||
|
await invalidateCache(cacheKey);
|
||||||
|
await withCache<schema.levelTableTypes>(
|
||||||
|
cacheKey,
|
||||||
|
async () => {
|
||||||
|
const result = await db
|
||||||
|
.update(schema.levelTable)
|
||||||
|
.set({
|
||||||
|
xp: userData.xp,
|
||||||
|
level: userData.level,
|
||||||
|
lastMessageTimestamp: userData.lastMessageTimestamp,
|
||||||
|
messagesSent: userData.messagesSent,
|
||||||
|
})
|
||||||
|
.where(eq(schema.levelTable.discordId, discordId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return result[0] as schema.levelTableTypes;
|
||||||
|
},
|
||||||
|
300,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
leveledUp: userData.level > currentLevel,
|
||||||
|
newLevel: userData.level,
|
||||||
|
oldLevel: currentLevel,
|
||||||
|
messagesSent: userData.messagesSent,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return handleDbError('Error adding XP to user', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a user's rank on the XP leaderboard
|
||||||
|
* @param discordId - Discord ID of the user
|
||||||
|
* @returns User's rank on the leaderboard
|
||||||
|
*/
|
||||||
|
export async function getUserRank(discordId: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot get user rank');
|
||||||
|
}
|
||||||
|
|
||||||
|
const leaderboardCache = await getLeaderboardData();
|
||||||
|
|
||||||
|
if (leaderboardCache) {
|
||||||
|
const userIndex = leaderboardCache.findIndex(
|
||||||
|
(member) => member.discordId === discordId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userIndex !== -1) {
|
||||||
|
return userIndex + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
} catch (error) {
|
||||||
|
return handleDbError('Failed to get user rank', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear leaderboard cache
|
||||||
|
*/
|
||||||
|
export async function invalidateLeaderboardCache(): Promise<void> {
|
||||||
|
await invalidateCache('xp-leaderboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to get or create leaderboard data
|
||||||
|
* @returns Array of leaderboard data
|
||||||
|
*/
|
||||||
|
async function getLeaderboardData(): Promise<
|
||||||
|
Array<{
|
||||||
|
discordId: string;
|
||||||
|
xp: number;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot get leaderboard data');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = 'xp-leaderboard';
|
||||||
|
return withCache<Array<{ discordId: string; xp: number }>>(
|
||||||
|
cacheKey,
|
||||||
|
async () => {
|
||||||
|
return await db
|
||||||
|
.select({
|
||||||
|
discordId: schema.levelTable.discordId,
|
||||||
|
xp: schema.levelTable.xp,
|
||||||
|
})
|
||||||
|
.from(schema.levelTable)
|
||||||
|
.orderBy(desc(schema.levelTable.xp));
|
||||||
|
},
|
||||||
|
300,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return handleDbError('Failed to get leaderboard data', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increments the user's reaction count
|
||||||
|
* @param userId - Discord user ID
|
||||||
|
* @returns The updated reaction count
|
||||||
|
*/
|
||||||
|
export async function incrementUserReactionCount(
|
||||||
|
userId: string,
|
||||||
|
): Promise<number> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error(
|
||||||
|
'Database not initialized, cannot increment reaction count',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const levelData = await getUserLevel(userId);
|
||||||
|
|
||||||
|
const newCount = (levelData.reactionCount || 0) + 1;
|
||||||
|
await db
|
||||||
|
.update(schema.levelTable)
|
||||||
|
.set({ reactionCount: newCount })
|
||||||
|
.where(eq(schema.levelTable.discordId, userId));
|
||||||
|
await invalidateCache(`level-${userId}`);
|
||||||
|
|
||||||
|
return newCount;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error incrementing user reaction count:', error);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrements the user's reaction count (but not below zero)
|
||||||
|
* @param userId - Discord user ID
|
||||||
|
* @returns The updated reaction count
|
||||||
|
*/
|
||||||
|
export async function decrementUserReactionCount(
|
||||||
|
userId: string,
|
||||||
|
): Promise<number> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error(
|
||||||
|
'Database not initialized, cannot increment reaction count',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const levelData = await getUserLevel(userId);
|
||||||
|
|
||||||
|
const newCount = Math.max(0, levelData.reactionCount - 1);
|
||||||
|
await db
|
||||||
|
.update(schema.levelTable)
|
||||||
|
.set({ reactionCount: newCount < 0 ? 0 : newCount })
|
||||||
|
.where(eq(schema.levelTable.discordId, userId));
|
||||||
|
await invalidateCache(`level-${userId}`);
|
||||||
|
|
||||||
|
return newCount;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error decrementing user reaction count:', error);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the user's reaction count
|
||||||
|
* @param userId - Discord user ID
|
||||||
|
* @returns The user's reaction count
|
||||||
|
*/
|
||||||
|
export async function getUserReactionCount(userId: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot get user reaction count');
|
||||||
|
}
|
||||||
|
|
||||||
|
const levelData = await getUserLevel(userId);
|
||||||
|
return levelData.reactionCount;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting user reaction count:', error);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the XP leaderboard
|
||||||
|
* @param limit - Number of entries to return
|
||||||
|
* @returns Array of leaderboard entries
|
||||||
|
*/
|
||||||
|
export async function getLevelLeaderboard(
|
||||||
|
limit = 10,
|
||||||
|
): Promise<schema.levelTableTypes[]> {
|
||||||
|
try {
|
||||||
|
await ensureDbInitialized();
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
console.error('Database not initialized, cannot get level leaderboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
const leaderboardCache = await getLeaderboardData();
|
||||||
|
|
||||||
|
if (leaderboardCache) {
|
||||||
|
const limitedCache = leaderboardCache.slice(0, limit);
|
||||||
|
|
||||||
|
const fullLeaderboard = await Promise.all(
|
||||||
|
limitedCache.map(async (entry) => {
|
||||||
|
const userData = await getUserLevel(entry.discordId);
|
||||||
|
return userData;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return fullLeaderboard;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await db
|
||||||
|
.select()
|
||||||
|
.from(schema.levelTable)
|
||||||
|
.orderBy(desc(schema.levelTable.xp))
|
||||||
|
.limit(limit)) as schema.levelTableTypes[];
|
||||||
|
} catch (error) {
|
||||||
|
return handleDbError('Failed to get leaderboard', error as Error);
|
||||||
|
}
|
||||||
|
}
|