Compare commits

...

129 commits

Author SHA1 Message Date
Ahmad
d9f670fcf4
Merge pull request #365 from ahmadk953/dependabot/npm_and_yarn/tsx-4.19.4
Some checks failed
NodeJS Build and Compile / build (23.x) (push) Has been cancelled
Commitlint / Run commitlint scanning (push) Has been cancelled
ESLint / Run eslint scanning (push) Has been cancelled
chore(deps-dev): bump tsx from 4.19.3 to 4.19.4
2025-04-29 20:16:08 -04:00
dependabot[bot]
6d15085ed3
chore(deps-dev): bump tsx from 4.19.3 to 4.19.4
Bumps [tsx](https://github.com/privatenumber/tsx) from 4.19.3 to 4.19.4.
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.19.3...v4.19.4)

---
updated-dependencies:
- dependency-name: tsx
  dependency-version: 4.19.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-29 21:49:37 +00:00
Ahmad
c22cedf2f0
chore: merge pull request #361 from ahmadk953/dependabot/npm_and_yarn/typescript-eslint/eslint-plugin-8.31.1
chore(deps-dev): bump @typescript-eslint/eslint-plugin from 8.31.0 to 8.31.1
2025-04-28 19:25:54 -04:00
dependabot[bot]
46d222d8ec
chore(deps-dev): bump @typescript-eslint/eslint-plugin
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 8.31.0 to 8.31.1.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.31.1/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.31.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-28 23:15:45 +00:00
Ahmad
db616967b5
chore: merge pull request #362 from ahmadk953/dependabot/npm_and_yarn/types/node-22.15.3
chore(deps-dev): bump @types/node from 22.15.2 to 22.15.3
2025-04-28 19:14:55 -04:00
Ahmad
2a0c6110f4
chore: merge pull request #363 from ahmadk953/dependabot/npm_and_yarn/discord.js-14.19.2
chore(deps): bump discord.js from 14.18.0 to 14.19.2
2025-04-28 19:14:36 -04:00
Ahmad
11fa90d552
chore: merge pull request #364 from ahmadk953/dependabot/npm_and_yarn/typescript-eslint/parser-8.31.1
chore(deps-dev): bump @typescript-eslint/parser from 8.31.0 to 8.31.1
2025-04-28 19:14:16 -04:00
dependabot[bot]
47942ecb93
chore(deps-dev): bump @typescript-eslint/parser from 8.31.0 to 8.31.1
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 8.31.0 to 8.31.1.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.31.1/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.31.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-28 22:58:56 +00:00
dependabot[bot]
72977ab057
chore(deps): bump discord.js from 14.18.0 to 14.19.2
Bumps [discord.js](https://github.com/discordjs/discord.js/tree/HEAD/packages/discord.js) from 14.18.0 to 14.19.2.
- [Release notes](https://github.com/discordjs/discord.js/releases)
- [Changelog](https://github.com/discordjs/discord.js/blob/14.19.2/packages/discord.js/CHANGELOG.md)
- [Commits](https://github.com/discordjs/discord.js/commits/14.19.2/packages/discord.js)

---
updated-dependencies:
- dependency-name: discord.js
  dependency-version: 14.19.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-28 22:58:32 +00:00
dependabot[bot]
fab4b7b2f7
chore(deps-dev): bump @types/node from 22.15.2 to 22.15.3
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 22.15.2 to 22.15.3.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 22.15.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-28 22:56:21 +00:00
Ahmad
22eff7f672
chore: merge pull request #358 from ahmadk953/dependabot/npm_and_yarn/pg-8.15.6
chore(deps): bump pg from 8.15.5 to 8.15.6
2025-04-25 19:12:31 -04:00
Ahmad
60bdbe2db5
chore: merge pull request #359 from ahmadk953/dependabot/npm_and_yarn/drizzle-orm-0.43.1
chore(deps): bump drizzle-orm from 0.43.0 to 0.43.1
2025-04-25 19:12:18 -04:00
Ahmad
40bc8a3720
chore: merge pull request #360 from ahmadk953/dependabot/npm_and_yarn/types/node-22.15.2
chore(deps-dev): bump @types/node from 22.15.0 to 22.15.2
2025-04-25 19:12:03 -04:00
dependabot[bot]
82538289ac
chore(deps-dev): bump @types/node from 22.15.0 to 22.15.2
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 22.15.0 to 22.15.2.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 22.15.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-25 21:28:05 +00:00
dependabot[bot]
2b5ab48923
chore(deps): bump drizzle-orm from 0.43.0 to 0.43.1
Bumps [drizzle-orm](https://github.com/drizzle-team/drizzle-orm) from 0.43.0 to 0.43.1.
- [Release notes](https://github.com/drizzle-team/drizzle-orm/releases)
- [Commits](https://github.com/drizzle-team/drizzle-orm/compare/0.43.0...0.43.1)

---
updated-dependencies:
- dependency-name: drizzle-orm
  dependency-version: 0.43.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-25 21:27:48 +00:00
dependabot[bot]
a26ad631bb
chore(deps): bump pg from 8.15.5 to 8.15.6
Bumps [pg](https://github.com/brianc/node-postgres/tree/HEAD/packages/pg) from 8.15.5 to 8.15.6.
- [Changelog](https://github.com/brianc/node-postgres/blob/master/CHANGELOG.md)
- [Commits](https://github.com/brianc/node-postgres/commits/pg@8.15.6/packages/pg)

---
updated-dependencies:
- dependency-name: pg
  dependency-version: 8.15.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-25 21:27:19 +00:00
Ahmad
ea978ffacc
chore: merge pull request #355 from ahmadk953/dependabot/npm_and_yarn/types/pg-8.11.14
chore(deps-dev): bump @types/pg from 8.11.13 to 8.11.14
2025-04-24 18:56:09 -04:00
dependabot[bot]
469f871b72
chore(deps-dev): bump @types/pg from 8.11.13 to 8.11.14
Bumps [@types/pg](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/pg) from 8.11.13 to 8.11.14.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/pg)

---
updated-dependencies:
- dependency-name: "@types/pg"
  dependency-version: 8.11.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-24 22:48:26 +00:00
Ahmad
18b87d2910
chore: merge pull request #356 from ahmadk953/dependabot/npm_and_yarn/drizzle-orm-0.43.0
chore(deps): bump drizzle-orm from 0.42.0 to 0.43.0
2025-04-24 18:47:19 -04:00
Ahmad
d082a061c8
Merge pull request #357 from ahmadk953/dependabot/npm_and_yarn/types/node-22.15.0
chore(deps-dev): bump @types/node from 22.14.1 to 22.15.0
2025-04-24 18:47:00 -04:00
dependabot[bot]
9b55669f25
chore(deps-dev): bump @types/node from 22.14.1 to 22.15.0
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 22.14.1 to 22.15.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 22.15.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-24 21:31:11 +00:00
dependabot[bot]
2e9970aa62
chore(deps): bump drizzle-orm from 0.42.0 to 0.43.0
Bumps [drizzle-orm](https://github.com/drizzle-team/drizzle-orm) from 0.42.0 to 0.43.0.
- [Release notes](https://github.com/drizzle-team/drizzle-orm/releases)
- [Commits](https://github.com/drizzle-team/drizzle-orm/compare/0.42.0...0.43.0)

---
updated-dependencies:
- dependency-name: drizzle-orm
  dependency-version: 0.43.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-24 21:30:57 +00:00
Ahmad
e906d86e5d
Merge pull request #354 from ahmadk953/dependabot/npm_and_yarn/pg-8.15.5
chore(deps): bump pg from 8.15.1 to 8.15.5
2025-04-23 18:12:33 -04:00
dependabot[bot]
6f97cae885
chore(deps): bump pg from 8.15.1 to 8.15.5
Bumps [pg](https://github.com/brianc/node-postgres/tree/HEAD/packages/pg) from 8.15.1 to 8.15.5.
- [Changelog](https://github.com/brianc/node-postgres/blob/master/CHANGELOG.md)
- [Commits](https://github.com/brianc/node-postgres/commits/pg@8.15.5/packages/pg)

---
updated-dependencies:
- dependency-name: pg
  dependency-version: 8.15.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-23 22:04:11 +00:00
Ahmad
48e84d71d2
chore: merge pull request #353 from ahmadk953/dependabot/npm_and_yarn/pg-8.15.1
chore(deps): bump pg from 8.14.1 to 8.15.1
2025-04-22 19:30:23 -04:00
dependabot[bot]
e6cd3b135f
chore(deps): bump pg from 8.14.1 to 8.15.1
Bumps [pg](https://github.com/brianc/node-postgres/tree/HEAD/packages/pg) from 8.14.1 to 8.15.1.
- [Changelog](https://github.com/brianc/node-postgres/blob/master/CHANGELOG.md)
- [Commits](https://github.com/brianc/node-postgres/commits/pg@8.15.1/packages/pg)

---
updated-dependencies:
- dependency-name: pg
  dependency-version: 8.15.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-22 21:27:15 +00:00
Ahmad
fb9426f7ad
chore: merge pull request #350 from ahmadk953/dependabot/npm_and_yarn/eslint/js-9.25.1
chore(deps-dev): bump @eslint/js from 9.25.0 to 9.25.1
2025-04-21 19:52:39 -04:00
Ahmad
3a0352ab10
Merge pull request #349 from ahmadk953/dependabot/npm_and_yarn/typescript-eslint/parser-8.31.0
chore(deps-dev): bump @typescript-eslint/parser from 8.30.1 to 8.31.0
2025-04-21 19:52:24 -04:00
dependabot[bot]
1b563a8c37
chore(deps-dev): bump @typescript-eslint/parser from 8.30.1 to 8.31.0
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 8.30.1 to 8.31.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.31.0/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.31.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-21 23:47:39 +00:00
dependabot[bot]
5238063419
chore(deps-dev): bump @eslint/js from 9.25.0 to 9.25.1
Bumps [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) from 9.25.0 to 9.25.1.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/commits/v9.25.1/packages/js)

---
updated-dependencies:
- dependency-name: "@eslint/js"
  dependency-version: 9.25.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-21 23:47:22 +00:00
Ahmad
0c03dc2462
Merge pull request #351 from ahmadk953/dependabot/npm_and_yarn/eslint-9.25.1
chore(deps-dev): bump eslint from 9.25.0 to 9.25.1
2025-04-21 19:46:12 -04:00
Ahmad
62f59819c4
Merge pull request #352 from ahmadk953/dependabot/npm_and_yarn/typescript-eslint/eslint-plugin-8.31.0
chore(deps-dev): bump @typescript-eslint/eslint-plugin from 8.30.1 to 8.31.0
2025-04-21 19:45:57 -04:00
dependabot[bot]
7995f59ba1
chore(deps-dev): bump @typescript-eslint/eslint-plugin
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 8.30.1 to 8.31.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.31.0/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.31.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-21 21:49:54 +00:00
dependabot[bot]
b812701a21
chore(deps-dev): bump eslint from 9.25.0 to 9.25.1
Bumps [eslint](https://github.com/eslint/eslint) from 9.25.0 to 9.25.1.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v9.25.0...v9.25.1)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 9.25.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-21 21:49:28 +00:00
Ahmad
bfaaed9c96
Update helpers.ts
Signed-off-by: Ahmad <103906421+ahmadk953@users.noreply.github.com>
2025-04-19 23:08:21 -04:00
20bb1ede8e
docs: add self-hosting docs - GITBOOK-3 2025-04-19 06:08:36 +00:00
Ahmad
daa231ea40
chore: merge pull request #348 from ahmadk953/local-ssl
modified code to use self-signed ssl certificates
2025-04-19 02:03:59 -04:00
ahmadk953
6a915fe2d7
chore: fix merge conflicts 2025-04-19 02:00:56 -04:00
ahmadk953
4def8df3fe
chore: added TLS error handling to database and cache 2025-04-19 01:56:24 -04:00
ahmadk953
7fd9497fb8
chore: modified code to use self-signed ssl certificates 2025-04-19 01:23:19 -04:00
ahmadk953
a5b33da0fa
chore: modified code to use self-signed ssl certificates 2025-04-19 01:20:58 -04:00
Ahmad
b961218206
Merge pull request #346 from ahmadk953/dependabot/npm_and_yarn/eslint/js-9.25.0
chore(deps-dev): bump @eslint/js from 9.24.0 to 9.25.0
2025-04-18 21:03:23 -04:00
dependabot[bot]
ec28339f61
chore(deps-dev): bump @eslint/js from 9.24.0 to 9.25.0
Bumps [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) from 9.24.0 to 9.25.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/commits/v9.25.0/packages/js)

---
updated-dependencies:
- dependency-name: "@eslint/js"
  dependency-version: 9.25.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-19 00:50:54 +00:00
Ahmad
311cabec92
Merge pull request #347 from ahmadk953/dependabot/npm_and_yarn/eslint-9.25.0
chore(deps-dev): bump eslint from 9.24.0 to 9.25.0
2025-04-18 20:49:23 -04:00
dependabot[bot]
7887052f22
chore(deps-dev): bump eslint from 9.24.0 to 9.25.0
Bumps [eslint](https://github.com/eslint/eslint) from 9.24.0 to 9.25.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v9.24.0...v9.25.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 9.25.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-18 21:23:06 +00:00
Ahmad
d050976484
fix(bot): fix incorrect import
Signed-off-by: Ahmad <103906421+ahmadk953@users.noreply.github.com>
2025-04-18 00:08:46 -04:00
Ahmad
0610bd41c4
feat: merge pull request #345 from ahmadk953/docker-configuration
add docker-compose for setting up bot services
2025-04-17 22:39:48 -04:00
Ahmad
654bdce717
chore: remove initdb mount as it's not needed
Signed-off-by: Ahmad <103906421+ahmadk953@users.noreply.github.com>
2025-04-17 22:35:19 -04:00
Ahmad
6517b51f22
chore: update .gitignore
Signed-off-by: Ahmad <103906421+ahmadk953@users.noreply.github.com>
2025-04-17 22:34:41 -04:00
Ahmad
022ab6d272
chore: update .gitignore to ignore initdb directory
Signed-off-by: Ahmad <103906421+ahmadk953@users.noreply.github.com>
2025-04-17 22:24:52 -04:00
Ahmad
0a4a3c0af4
chore: update .gitignore to ignore .env files
Signed-off-by: Ahmad <103906421+ahmadk953@users.noreply.github.com>
2025-04-17 22:23:24 -04:00
Ahmad
a449129a61
chore: create .env.example for docker compose file
Signed-off-by: Ahmad <103906421+ahmadk953@users.noreply.github.com>
2025-04-17 22:22:49 -04:00
Ahmad
70dff9d696
chore: create docker-compose.yml
Signed-off-by: Ahmad <103906421+ahmadk953@users.noreply.github.com>
2025-04-17 22:21:30 -04:00
Ahmad
e9abfa812e
Merge pull request #344 from ahmadk953/dependabot/npm_and_yarn/lint-staged-15.5.1
chore(deps-dev): bump lint-staged from 15.5.0 to 15.5.1
2025-04-17 19:58:25 -04:00
dependabot[bot]
986a6a67d0
chore(deps-dev): bump lint-staged from 15.5.0 to 15.5.1
Bumps [lint-staged](https://github.com/lint-staged/lint-staged) from 15.5.0 to 15.5.1.
- [Release notes](https://github.com/lint-staged/lint-staged/releases)
- [Changelog](https://github.com/lint-staged/lint-staged/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lint-staged/lint-staged/compare/v15.5.0...v15.5.1)

---
updated-dependencies:
- dependency-name: lint-staged
  dependency-version: 15.5.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-17 22:00:55 +00:00
Ahmad
890ee028cc
chore: update README.md
Signed-off-by: Ahmad <103906421+ahmadk953@users.noreply.github.com>
2025-04-17 01:36:00 -04:00
Ahmad
15cb83be7d
feat(bot): merge pull request #343 from ahmadk953/utils
chore(bot): finish base bot
2025-04-17 01:31:29 -04:00
Ahmad
77c4b75440
chore(bot): fixed remaining issues 2025-04-17 01:24:52 -04:00
Ahmad
7c2a99daf5
chore: improve safety of commands 2025-04-17 01:05:10 -04:00
Ahmad
83bbf7b098
fix(bot): fixed missing await statement in memberEvents file
Signed-off-by: Ahmad <103906421+ahmadk953@users.noreply.github.com>
2025-04-16 23:39:28 -04:00
Ahmad
b615d25964
feat: added config and help commands 2025-04-16 23:34:33 -04:00
Ahmad
20af09b279
feat: add kick, mute, and unmute commands 2025-04-16 22:10:47 -04:00
Ahmad
49d274f2be
fix: fixed temp bans not expiring after they're finished after a bot restart 2025-04-16 20:18:16 -04:00
Ahmad
14667ad69f
chore: add option to undeploy commands and not deploy on start 2025-04-16 19:20:17 -04:00
Ahmad
072c34d778
pr: merge pull request #304 from ahmadk953/fun-features 2025-04-16 18:12:08 -04:00
Ahmad
be8df5f6a2
chore: split db file into multiple files and centralized pagination 2025-04-16 17:57:17 -04:00
Ahmad
2f5c3499e7
feat: add achievement system
Signed-off-by: Ahmad <103906421+ahmadk953@users.noreply.github.com>
2025-04-16 16:52:44 -04:00
Ahmad
830838a6a1
chore: merge branch 'main' into fun-features 2025-04-15 22:16:05 -04:00
Ahmad
8df866037a
Merge pull request #341 from ahmadk953/dependabot/npm_and_yarn/drizzle-orm-0.42.0
chore(deps): bump drizzle-orm from 0.41.0 to 0.42.0
2025-04-15 22:13:09 -04:00
dependabot[bot]
08cc92cff3
chore(deps): bump drizzle-orm from 0.41.0 to 0.42.0
Bumps [drizzle-orm](https://github.com/drizzle-team/drizzle-orm) from 0.41.0 to 0.42.0.
- [Release notes](https://github.com/drizzle-team/drizzle-orm/releases)
- [Commits](https://github.com/drizzle-team/drizzle-orm/compare/0.41.0...0.42.0)

---
updated-dependencies:
- dependency-name: drizzle-orm
  dependency-version: 0.42.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-16 00:37:51 +00:00
Ahmad
ca7be976f6
Merge pull request #342 from ahmadk953/dependabot/npm_and_yarn/drizzle-kit-0.31.0
chore(deps-dev): bump drizzle-kit from 0.30.6 to 0.31.0
2025-04-15 20:36:21 -04:00
Ahmad
e24d063b95
chore: merge branch 'fun-features' of origin into fun-features 2025-04-15 18:55:10 -04:00
dependabot[bot]
c286b7e1ca
chore(deps-dev): bump drizzle-kit from 0.30.6 to 0.31.0
Bumps [drizzle-kit](https://github.com/drizzle-team/drizzle-orm) from 0.30.6 to 0.31.0.
- [Release notes](https://github.com/drizzle-team/drizzle-orm/releases)
- [Commits](https://github.com/drizzle-team/drizzle-orm/compare/drizzle-kit@0.30.6...drizzle-kit@0.31.0)

---
updated-dependencies:
- dependency-name: drizzle-kit
  dependency-version: 0.31.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-15 21:36:49 +00:00
Ahmad
7704ff1ad6
chore: create FUNDING.yml
Signed-off-by: Ahmad <103906421+ahmadk953@users.noreply.github.com>
2025-04-14 23:11:24 -04:00
Ahmad
6b3de082d1
chore: update yarn version 2025-04-14 22:27:08 -04:00
Ahmad
2d6d45cad1
Merge branch 'main' of https://github.com/ahmadk953/poixpixel-discord-bot into fun-features
chore: update local branch with upstream changes
2025-04-14 22:25:50 -04:00
Ahmad
60aeb7033c
Merge pull request #337 from ahmadk953/dependabot/npm_and_yarn/types/pg-8.11.13
chore(deps-dev): bump @types/pg from 8.11.11 to 8.11.13
2025-04-14 18:10:47 -04:00
Ahmad
7c4b0fdb5d
Merge pull request #339 from ahmadk953/dependabot/npm_and_yarn/typescript-eslint/parser-8.30.1
chore(deps-dev): bump @typescript-eslint/parser from 8.29.1 to 8.30.1
2025-04-14 18:10:38 -04:00
dependabot[bot]
d90f67a164
chore(deps-dev): bump @types/pg from 8.11.11 to 8.11.13
Bumps [@types/pg](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/pg) from 8.11.11 to 8.11.13.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/pg)

---
updated-dependencies:
- dependency-name: "@types/pg"
  dependency-version: 8.11.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-14 22:05:54 +00:00
dependabot[bot]
5c748fbb79
chore(deps-dev): bump @typescript-eslint/parser from 8.29.1 to 8.30.1
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 8.29.1 to 8.30.1.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.30.1/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.30.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-14 22:05:38 +00:00
Ahmad
a1f9c39b9f
Merge pull request #338 from ahmadk953/dependabot/npm_and_yarn/types/node-22.14.1
chore(deps-dev): bump @types/node from 22.14.0 to 22.14.1
2025-04-14 18:04:27 -04:00
Ahmad
e81750509b
Merge pull request #340 from ahmadk953/dependabot/npm_and_yarn/typescript-eslint/eslint-plugin-8.30.1
chore(deps-dev): bump @typescript-eslint/eslint-plugin from 8.29.1 to 8.30.1
2025-04-14 18:04:12 -04:00
Ahmad
82ece53d97
Merge branch 'main' of https://github.com/ahmadk953/poixpixel-discord-bot into fun-features
chore: update local branch with upstream changes
2025-04-14 18:00:36 -04:00
dependabot[bot]
ebae678eee
chore(deps-dev): bump @typescript-eslint/eslint-plugin
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 8.29.1 to 8.30.1.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.30.1/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.30.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-14 21:38:23 +00:00
dependabot[bot]
f1713a95ce
chore(deps-dev): bump @types/node from 22.14.0 to 22.14.1
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 22.14.0 to 22.14.1.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 22.14.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-14 21:37:15 +00:00
6bf290d447
docs: update file names - GITBOOK-2 2025-04-14 18:57:40 +00:00
a859e0ea4f
docs: Basic Documentation - GITBOOK-1 2025-04-14 05:29:03 +00:00
Ahmad
2a324b501b
chore: update README.md
Signed-off-by: Ahmad <103906421+ahmadk953@users.noreply.github.com>
2025-04-14 01:16:00 -04:00
Ahmad
9aabe2885b
chore: update and clean up imports 2025-04-13 16:48:18 -04:00
Ahmad
d9d5f087e7
feat: add giveaway system
Signed-off-by: Ahmad <103906421+ahmadk953@users.noreply.github.com>
2025-04-13 16:13:14 -04:00
Ahmad
e898a9238d
chore: merge branch 'main' of https://github.com/ahmadk953/poixpixel-discord-bot into fun-features 2025-04-12 16:52:05 -04:00
Ahmad
97a921048f
Merge pull request #336 from ahmadk953/dependabot/npm_and_yarn/ioredis-5.6.1 2025-04-11 21:54:12 -04:00
dependabot[bot]
b91577958a
Bump ioredis from 5.6.0 to 5.6.1
Bumps [ioredis](https://github.com/luin/ioredis) from 5.6.0 to 5.6.1.
- [Release notes](https://github.com/luin/ioredis/releases)
- [Changelog](https://github.com/redis/ioredis/blob/main/CHANGELOG.md)
- [Commits](https://github.com/luin/ioredis/compare/v5.6.0...v5.6.1)

---
updated-dependencies:
- dependency-name: ioredis
  dependency-version: 5.6.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-11 21:20:19 +00:00
Ahmad
0997da84af
Merge pull request #335 from ahmadk953/dependabot/npm_and_yarn/eslint-config-prettier-10.1.2
Bump eslint-config-prettier from 10.1.1 to 10.1.2
2025-04-10 20:49:38 -04:00
dependabot[bot]
66f94369fb
Bump eslint-config-prettier from 10.1.1 to 10.1.2
Bumps [eslint-config-prettier](https://github.com/prettier/eslint-config-prettier) from 10.1.1 to 10.1.2.
- [Release notes](https://github.com/prettier/eslint-config-prettier/releases)
- [Changelog](https://github.com/prettier/eslint-config-prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/eslint-config-prettier/compare/v10.1.1...v10.1.2)

---
updated-dependencies:
- dependency-name: eslint-config-prettier
  dependency-version: 10.1.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-10 21:57:34 +00:00
Ahmad
f896f5e6ad
Merge pull request #332 from ahmadk953/dependabot/npm_and_yarn/typescript-eslint/eslint-plugin-8.29.1
Bump @typescript-eslint/eslint-plugin from 8.29.0 to 8.29.1
2025-04-07 20:43:12 -04:00
dependabot[bot]
9dad65e534
Bump @typescript-eslint/eslint-plugin from 8.29.0 to 8.29.1
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 8.29.0 to 8.29.1.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.29.1/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.29.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-08 00:39:38 +00:00
Ahmad
a7ef041f3d
Merge pull request #333 from ahmadk953/dependabot/npm_and_yarn/typescript-eslint/parser-8.29.1
Bump @typescript-eslint/parser from 8.29.0 to 8.29.1
2025-04-07 20:38:23 -04:00
Ahmad
8785be9698
Merge pull request #334 from ahmadk953/dependabot/npm_and_yarn/typescript-5.8.3
Bump typescript from 5.8.2 to 5.8.3
2025-04-07 20:38:14 -04:00
dependabot[bot]
0ebfe731ca
Bump typescript from 5.8.2 to 5.8.3
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.8.2 to 5.8.3.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release-publish.yml)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.8.2...v5.8.3)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 5.8.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-07 22:54:17 +00:00
dependabot[bot]
089c8d022e
Bump @typescript-eslint/parser from 8.29.0 to 8.29.1
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 8.29.0 to 8.29.1.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.29.1/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.29.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-07 22:48:55 +00:00
Ahmad
990ca0769b
Merge pull request #329 from ahmadk953/dependabot/npm_and_yarn/eslint-9.24.0 2025-04-04 20:53:28 -04:00
dependabot[bot]
7449fd3a5f
Bump eslint from 9.23.0 to 9.24.0
Bumps [eslint](https://github.com/eslint/eslint) from 9.23.0 to 9.24.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v9.23.0...v9.24.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 9.24.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-05 00:52:48 +00:00
Ahmad
22f4be62d0
Merge pull request #330 from ahmadk953/dependabot/npm_and_yarn/napi-rs/canvas-0.1.69 2025-04-04 20:51:38 -04:00
Ahmad
fb3960acc4
Merge pull request #331 from ahmadk953/dependabot/npm_and_yarn/eslint/js-9.24.0 2025-04-04 20:51:24 -04:00
dependabot[bot]
7c2add12b6
Bump @eslint/js from 9.23.0 to 9.24.0
Bumps [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) from 9.23.0 to 9.24.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/commits/v9.24.0/packages/js)

---
updated-dependencies:
- dependency-name: "@eslint/js"
  dependency-version: 9.24.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-04 21:58:04 +00:00
dependabot[bot]
9327cd0d31
Bump @napi-rs/canvas from 0.1.68 to 0.1.69
Bumps [@napi-rs/canvas](https://github.com/Brooooooklyn/canvas) from 0.1.68 to 0.1.69.
- [Release notes](https://github.com/Brooooooklyn/canvas/releases)
- [Changelog](https://github.com/Brooooooklyn/canvas/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Brooooooklyn/canvas/compare/v0.1.68...v0.1.69)

---
updated-dependencies:
- dependency-name: "@napi-rs/canvas"
  dependency-version: 0.1.69
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-04 21:57:43 +00:00
Ahmad
7fea10ffd5
chore: merge branch 'main' of https://github.com/ahmadk953/poixpixel-discord-bot into fun-features
Some checks failed
Commitlint / Run commitlint scanning (push) Has been cancelled
2025-03-27 20:54:13 -04:00
Ahmad
bdc20bcabb
chore: merge branch 'main' of https://github.com/ahmadk953/poixpixel-discord-bot into fun-features
Some checks failed
Commitlint / Run commitlint scanning (push) Has been cancelled
2025-03-25 20:51:16 -04:00
Ahmad
6a34cf926e
build: updated package.json and yarn.lock files 2025-03-25 20:45:10 -04:00
Ahmad
30cc0e9a8d
fix: updated drizzle config to match new config.json structure
Some checks failed
Commitlint / Run commitlint scanning (push) Has been cancelled
2025-03-20 21:12:23 -04:00
Ahmad
6df3cffeee
Merge branch 'main' of https://github.com/ahmadk953/poixpixel-discord-bot into fun-features
Update local dependencies
2025-03-20 21:00:10 -04:00
Ahmad
e5bffdb889
fix(ci): fixed incorrect formatting for commitlint GitHub CI action
Some checks failed
Commitlint / Run commitlint scanning (push) Has been cancelled
Signed-off-by: Ahmad <103906421+ahmadk953@users.noreply.github.com>
2025-03-17 21:23:25 -04:00
Ahmad
c2316e8f3d
fix(ci): fixed commitlint GitHub CI action 2025-03-17 21:21:08 -04:00
Ahmad
19247de2b8
ci: added commitlint GitHub CI action 2025-03-17 21:13:44 -04:00
Ahmad
f91ac5f04d
build: added basic husky + commitlint + lint-staged setup 2025-03-17 21:05:32 -04:00
Ahmad
0db2a4235f
Merge branch 'main' of https://github.com/ahmadk953/poixpixel-discord-bot into fun-features
Update local dependencies
2025-03-17 20:33:48 -04:00
Ahmad
890ca26c78
Added code coments, refactored db.ts and redis.ts, and added two new commands 2025-03-16 20:31:43 -04:00
Ahmad
b3fbd2358b
Updated README.md 2025-03-15 20:52:19 -04:00
Ahmad
5dbc5e0f4a
Merge branch 'main' of https://github.com/ahmadk953/poixpixel-discord-bot into fun-features
Update local dependencies
2025-03-11 21:29:07 -04:00
Ahmad
84bf5b272c
Update src/db/schema.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Ahmad <103906421+ahmadk953@users.noreply.github.com>
2025-03-09 15:55:28 -04:00
Ahmad
b5ce514397
Added Basic Leveling System and QoL Updates 2025-03-09 15:52:10 -04:00
Ahmad
7af6d5914d
Updated README 2025-03-08 05:21:07 -05:00
Ahmad
40942e2539
Added Basic Fact of the Day Feature 2025-03-08 00:29:19 -05:00
Ahmad
2139f2efa0
Merge branch 'main' of https://github.com/ahmadk953/poixpixel-discord-bot into fun-features
Update local dependencies
2025-03-07 17:39:41 -05:00
Ahmad
9994f86954
Merge branch 'main' of https://github.com/ahmadk953/poixpixel-discord-bot into fun-features
Update local dependencies
2025-03-04 17:28:19 -05:00
Ahmad
c47347e07d
Updated Yarn Version 2025-03-03 19:08:52 -05:00
Ahmad
dff79dd021
Merge branch 'main', commit '9e86f1f2f6' of https://github.com/ahmadk953/poixpixel-discord-bot into fun-features
Update local dependencies
2025-03-03 17:42:23 -05:00
Ahmad
95143d8c93
Added Counting Feature 2025-03-02 05:47:53 -05:00
141 changed files with 12391 additions and 1023 deletions

5
.commitlintrc Normal file
View file

@ -0,0 +1,5 @@
{
"extends": [
"@commitlint/config-conventional"
]
}

4
.env.example Normal file
View 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
View file

@ -0,0 +1,3 @@
github: ahmadk953
patreon: poixpixel
thanks_dev: u/gh/ahmadk953

39
.github/workflows/commitlint.yml vendored Normal file
View 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

View file

@ -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
View file

@ -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
View file

@ -0,0 +1 @@
yarn dlx commitlint --edit \

1
.husky/pre-commit Normal file
View file

@ -0,0 +1 @@
yarn lint-staged

1
.husky/pre-push Normal file
View file

@ -0,0 +1 @@
yarn compile

12
.lintstagedrc.mjs Normal file
View 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],
};

View file

@ -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
View file

@ -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
View file

@ -1,14 +0,0 @@
{
"tasks": [
{
"label": "build",
"type": "shell",
"command": "node",
"args": ["${workspaceFolder}/build/compile.js"],
"group": {
"kind": "build",
"isDefault": true
}
}
]
}

View file

@ -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``

Binary file not shown.

Binary file not shown.

BIN
assets/images/trophy.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View file

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Before After
Before After

View file

@ -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",
"serverInvite": "DISCORD_SERVER_INVITE_LINK",
"database": {
"dbConnectionString": "POSTGRESQL_CONNECTION_STRING", "dbConnectionString": "POSTGRESQL_CONNECTION_STRING",
"maxRetryAttempts": "MAX_RETRY_ATTEMPTS",
"retryDelay": "RETRY_DELAY_IN_MS"
},
"redis": {
"redisConnectionString": "REDIS_CONNECTION_STRING", "redisConnectionString": "REDIS_CONNECTION_STRING",
"retryAttempts": "RETRY_ATTEMPTS",
"initialRetryDelay": "INITIAL_RETRY_DELAY_IN_MS"
},
"channels": { "channels": {
"welcome": "WELCOME_CHANNEL_ID", "welcome": "WELCOME_CHANNEL_ID",
"logs": "LOG_CHAANNEL_ID" "logs": "LOG_CHANNEL_ID",
"counting": "COUNTING_CHANNEL_ID",
"factOfTheDay": "FACT_OF_THE_DAY_CHANNEL_ID",
"factApproval": "FACT_APPROVAL_CHANNEL_ID",
"advancements": "ADVANCEMENTS_CHANNEL_ID"
}, },
"roles": { "roles": {
"joinRoles": [ "joinRoles": [
"JOIN_ROLE_IDS" "JOIN_ROLE_IDS"
] ],
"levelRoles": [
{
"level": "LEVEL_NUMBER",
"roleId": "ROLE_ID"
},
{
"level": "LEVEL_NUMBER",
"roleId": "ROLE_ID"
},
{
"level": "LEVEL_NUMBER",
"roleId": "ROLE_ID"
}
],
"staffRoles": [
{
"name": "ROLE_NAME",
"roleId": "ROLE_ID"
},
{
"name": "ROLE_NAME",
"roleId": "ROLE_ID"
},
{
"name": "ROLE_NAME",
"roleId": "ROLE_ID"
}
],
"factPingRole": "FACT_OF_THE_DAY_ROLE_ID"
},
"leveling": {
"xpCooldown": "XP_COOLDOWN_IN_SECONDS",
"minXpAwarded": "MINIMUM_XP_AWARDED",
"maxXpAwarded": "MAXIMUM_XP_AWARDED"
} }
} }

62
docker-compose.yml Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

30
docs/bot/README.md Normal file
View 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 &#x26; 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
View 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)

View file

@ -0,0 +1,7 @@
---
description: Learn about all the configuration options and what they do
icon: list
---
# Configuration Options

View file

@ -0,0 +1,6 @@
---
icon: pen-to-square
---
# Updating the Bot

View file

@ -0,0 +1,6 @@
---
icon: clipboard-list
---
# Contribution Guidelines

View file

@ -0,0 +1,6 @@
---
icon: signs-post
---
# Introduction

View 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

View file

@ -0,0 +1,7 @@
---
description: Basic bot configuration options
icon: sliders
---
# Basic Configuration

View 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 &#x22;New Application&#x22;"><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 &#x22;OAuth2&#x22;"><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 &#x22;Copy&#x22; 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 &#x22;Installation&#x22;"><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 &#x22;Bot&#x22;"><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 &#x22;Advanced&#x22;"><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 &#x22;Copy Server ID&#x22;"><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 &#x22;Copy Channel ID&#x22;"><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 %}

View 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&#x20;
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 &#x22;64-bit Git for Windows Setup.&#x22;"><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 &#x22;Docker Desktop for Windows - x86_64&#x22;"><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.
&#x20;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).&#x20;
{% 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&#x20;
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 %}

View file

@ -0,0 +1,6 @@
---
icon: cloud
---
# Using a Cloud Provider

View file

@ -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
View 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

View file

@ -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"
} }

View 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;

View 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
View 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;

View 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;

View 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
View 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;

View file

@ -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,10 +31,15 @@ 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;
await interaction.deferReply({ flags: ['Ephemeral'] });
try {
const moderator = await interaction.guild.members.fetch(
interaction.user.id, interaction.user.id,
); );
const member = await interaction.guild?.members.fetch( const member = await interaction.guild.members.fetch(
interaction.options.get('member')!.value as string, interaction.options.get('member')!.value as string,
); );
const reason = interaction.options.get('reason')?.value as string; const reason = interaction.options.get('reason')?.value as string;
@ -44,26 +50,44 @@ const command: OptionsCommand = {
if ( if (
!interaction.memberPermissions?.has( !interaction.memberPermissions?.has(
PermissionsBitField.Flags.BanMembers, PermissionsBitField.Flags.BanMembers,
) || )
moderator!.roles.highest.position <= member!.roles.highest.position ||
!member?.bannable
) { ) {
await interaction.reply({ await interaction.editReply({
content: content: 'You do not have permission to ban members.',
'You do not have permission to ban members or this member cannot be banned.',
flags: ['Ephemeral'],
}); });
return; 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 { try {
await member.user.send( await member.user.send(
banDuration banDuration
? `You have been banned from ${interaction.guild!.name} for ${banDuration}. Reason: ${reason}. You can join back at ${new Date( ? `You have been banned from ${interaction.guild.name} for ${banDuration}. Reason: ${reason}. You can join back at ${until} using the link below:\n${invite}`
Date.now() + parseDuration(banDuration), : `You been indefinitely banned from ${interaction.guild.name}. Reason: ${reason}.`,
).toUTCString()} using the link below:\nhttps://discord.gg/KRTGjxx7gY`
: `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'],
}); });
} }
}, },

View 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;

View 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;

View file

@ -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;
await interaction.deferReply({ flags: ['Ephemeral'] });
try {
const userId = interaction.options.get('userid')?.value as string;
const reason = interaction.options.get('reason')?.value as string; const reason = interaction.options.get('reason')?.value as string;
if ( if (
!interaction.memberPermissions?.has(PermissionsBitField.Flags.BanMembers) !interaction.memberPermissions?.has(
PermissionsBitField.Flags.BanMembers,
)
) { ) {
await interaction.reply({ await interaction.editReply({
content: 'You do not have permission to unban users.', content: 'You do not have permission to unban users.',
flags: ['Ephemeral'],
}); });
return; return;
} }
try { 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'],
}); });
} }
}, },

View 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;

View file

@ -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;
await interaction.deferReply({ flags: ['Ephemeral'] });
try {
const moderator = await interaction.guild.members.fetch(
interaction.user.id, interaction.user.id,
); );
const member = await interaction.guild?.members.fetch( const member = await interaction.guild.members.fetch(
interaction.options.get('member')!.value as unknown as string, interaction.options.get('member')!.value as unknown as string,
); );
const reason = interaction.options.get('reason') const reason = interaction.options.getString('reason')!;
?.value as unknown as string;
if ( if (
!interaction.memberPermissions?.has( !interaction.memberPermissions?.has(
PermissionsBitField.Flags.ModerateMembers, PermissionsBitField.Flags.ModerateMembers,
) || )
moderator!.roles.highest.position <= member!.roles.highest.position
) { ) {
await interaction.reply({ await interaction.editReply({
content: 'You do not have permission to warn this member.', content: 'You do not have permission to warn members.',
flags: ['Ephemeral'], });
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; return;
} }
try {
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'],
}); });
} }
}, },

View file

@ -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'],
}); });
}, },
}; };

View file

@ -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
View 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
View 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;

View file

@ -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;
} }
} }

View file

@ -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`,
); );
}, },
}; };

View 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;

View 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;

View 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;

View file

@ -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)

View file

@ -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.`,
); );
}, },
}; };

View file

@ -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
View 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;

View file

@ -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;
if (memberData && memberData.length > 0) { return true;
return memberData;
} else {
await del('nonBotMembers');
return await getAllMembers();
}
} else {
const nonBotMembers = await db
.select()
.from(schema.memberTable)
.where(eq(schema.memberTable.currentlyInServer, true));
await setJson<(typeof schema.memberTable.$inferSelect)[]>(
'nonBotMembers',
nonBotMembers,
);
return nonBotMembers;
}
} catch (error) { } catch (error) {
console.error('Error getting all members: ', error); console.warn(
throw new DatabaseError('Failed to get all members: ', error as Error); 'Existing database connection is not responsive, creating a new one',
} );
}
export async function setMembers(nonBotMembers: any) {
try { try {
nonBotMembers.forEach(async (member: any) => { await dbPool.end();
const memberInfo = await db } catch (endError) {
.select() console.error('Error ending pool:', endError);
.from(schema.memberTable)
.where(eq(schema.memberTable.discordId, member.user.id));
if (memberInfo.length > 0) {
await updateMember({
discordId: member.user.id,
discordUsername: member.user.username,
currentlyInServer: true,
});
} else {
const members: typeof schema.memberTable.$inferInsert = {
discordId: member.user.id,
discordUsername: member.user.username,
};
await db.insert(schema.memberTable).values(members);
} }
});
} catch (error) {
console.error('Error setting members: ', error);
throw new DatabaseError('Failed to set members: ', error as Error);
} }
} }
export async function getMember(discordId: string) { // Log the database connection attempt
console.log(
`Connecting to database... (connectionString length: ${config.database.dbConnectionString.length})`,
);
// Create new connection pool
dbPool = new Pool({
connectionString: config.database.dbConnectionString,
ssl: (() => {
try { try {
if (await exists(`${discordId}-memberInfo`)) {
const cachedMember = await getJson<
typeof schema.memberTable.$inferSelect
>(`${discordId}-memberInfo`);
const cachedModerationHistory = await getJson<
(typeof schema.moderationTable.$inferSelect)[]
>(`${discordId}-moderationHistory`);
if (
cachedMember &&
'discordId' in cachedMember &&
cachedModerationHistory &&
cachedModerationHistory.length > 0
) {
return { return {
...cachedMember, ca: fs.readFileSync(path.resolve('./certs/psql-ca.crt')),
moderations: cachedModerationHistory, key: fs.readFileSync(path.resolve('./certs/psql-client.key')),
cert: fs.readFileSync(path.resolve('./certs/psql-server.crt')),
}; };
} else { } catch (error) {
await del(`${discordId}-memberInfo`); console.warn(
await del(`${discordId}-moderationHistory`); 'Failed to load certificates for database, using insecure connection:',
return await getMember(discordId); error,
);
return undefined;
} }
} else { })(),
const member = await db.query.memberTable.findFirst({ connectionTimeoutMillis: 10000,
where: eq(schema.memberTable.discordId, discordId),
with: {
moderations: true,
},
}); });
await setJson<typeof schema.memberTable.$inferSelect>( // Test connection
`${discordId}-memberInfo`, await dbPool.query('SELECT 1');
member!,
// 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,
); );
await setJson<(typeof schema.moderationTable.$inferSelect)[]>( hasNotifiedDbDisconnect = false;
`${discordId}-moderationHistory`, }
member!.moderations,
return true;
} catch (error) {
console.error('Failed to connect to database:', error);
isDbConnected = false;
connectionAttempts++;
// Handle max retry attempts exceeded
if (connectionAttempts >= MAX_DB_RETRY_ATTEMPTS) {
if (!hasNotifiedDbDisconnect && discordClient) {
const message = `Failed to connect to database after ${connectionAttempts} attempts.`;
console.error(message);
logManagerNotification(
NotificationType.DATABASE_CONNECTION_LOST,
`Error: ${error}`,
);
notifyManagers(
discordClient,
NotificationType.DATABASE_CONNECTION_LOST,
`Connection attempts exhausted after ${connectionAttempts} tries. The bot cannot function without database access and will now terminate.`,
);
hasNotifiedDbDisconnect = true;
}
// Terminate after sending notifications
setTimeout(() => {
console.error('Database connection failed, shutting down bot');
process.exit(1);
}, 3000);
return false;
}
// Retry connection with exponential backoff
const delay = Math.min(
INITIAL_DB_RETRY_DELAY * Math.pow(2, connectionAttempts - 1),
30000,
);
console.log(
`Retrying database connection in ${delay}ms... (Attempt ${connectionAttempts}/${MAX_DB_RETRY_ATTEMPTS})`,
); );
return member; setTimeout(initializeDatabaseConnection, delay);
}
} catch (error) { return false;
console.error('Error getting member: ', error);
throw new DatabaseError('Failed to get member: ', error as Error);
} }
} }
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) {
// ========================
// Helper Functions
// ========================
/**
* Ensures the database is initialized and returns a promise
* @returns Promise for database initialization
*/
export async function ensureDbInitialized(): Promise<void> {
await dbInitPromise;
if (!isDbConnected) {
dbInitPromise = initializeDatabaseConnection();
await dbInitPromise;
}
}
/**
* Checks if the database connection is active and working
* @returns Promise resolving to true if connected, false otherwise
*/
export async function ensureDatabaseConnection(): Promise<boolean> {
await ensureDbInitialized();
if (!isDbConnected) {
return await initializeDatabaseConnection();
}
try { try {
const result = await db await dbPool.query('SELECT 1');
.update(schema.memberTable) return true;
.set({
discordUsername,
currentlyInServer,
currentlyBanned,
})
.where(eq(schema.memberTable.discordId, discordId));
if (await exists(`${discordId}-memberInfo`)) {
await del(`${discordId}-memberInfo`);
}
if (await exists('nonBotMembers')) {
await del('nonBotMembers');
}
return result;
} catch (error) { } catch (error) {
console.error('Error updating member: ', error); console.error('Database connection test failed:', error);
throw new DatabaseError('Failed to update member: ', error as Error); isDbConnected = false;
return await initializeDatabaseConnection();
} }
} }
export async function updateMemberModerationHistory({ /**
discordId, * Generic error handler for database operations
moderatorDiscordId, * @param errorMessage - Error message to log
action, * @param error - Original error object
reason, * @throws {DatabaseError} - Always throws a wrapped database error
duration, */
createdAt, export const handleDbError = (errorMessage: string, error: Error): never => {
expiresAt, console.error(`${errorMessage}:`, error);
active,
}: schema.moderationTableTypes) { // Check if error is related to connection and attempt to reconnect
try { if (
const moderationEntry = { error.message.includes('connection') ||
discordId, error.message.includes('connect')
moderatorDiscordId, ) {
action, isDbConnected = false;
reason, ensureDatabaseConnection().catch((err) => {
duration, console.error('Failed to reconnect to database:', err);
createdAt, });
expiresAt, }
active,
throw new DatabaseError(errorMessage, error);
}; };
const result = await db
.insert(schema.moderationTable)
.values(moderationEntry);
if (await exists(`${discordId}-moderationHistory`)) { // ========================
await del(`${discordId}-moderationHistory`); // Cache Management
} // ========================
if (await exists(`${discordId}-memberInfo`)) {
await del(`${discordId}-memberInfo`);
}
return result; /**
} catch (error) { * Checks and retrieves cached data or fetches from database
console.error('Error updating moderation history: ', error); * @param cacheKey - Key to check in cache
throw new DatabaseError( * @param dbFetch - Function to fetch data from database
'Failed to update moderation history: ', * @param ttl - Time to live for cache in seconds
error as Error, * @returns Cached or freshly fetched data
); */
} export async function withCache<T>(
} cacheKey: string,
dbFetch: () => Promise<T>,
export async function getMemberModerationHistory(discordId: string) { ttl?: number,
): Promise<T> {
try { try {
if (await exists(`${discordId}-moderationHistory`)) { const cachedData = await getJson<T>(cacheKey);
return await getJson<(typeof schema.moderationTable.$inferSelect)[]>( if (cachedData !== null) {
`${discordId}-moderationHistory`, return cachedData;
);
} 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(
throw new DatabaseError( `Cache retrieval failed for ${cacheKey}, falling back to database:`,
'Failed to get moderation history: ', error,
error as Error,
); );
} }
const data = await dbFetch();
try {
await setJson(cacheKey, data, ttl);
} catch (error) {
console.warn(`Failed to cache data for ${cacheKey}:`, error);
} }
return data;
}
/**
* Invalidates a cache key if it exists
* @param cacheKey - Key to invalidate
*/
export async function invalidateCache(cacheKey: string): Promise<void> {
try {
if (await exists(cacheKey)) {
await del(cacheKey);
}
} catch (error) {
console.warn(`Error invalidating cache for key ${cacheKey}:`, 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';

View 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;
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

Some files were not shown because too many files have changed in this diff Show more