├── .cspell
└── custom-dictionary-workspace.txt
├── .dockerignore
├── .env.example
├── .envrc
├── .eslintrc.json
├── .gitignore
├── .gitlab-ci.yml
├── .releaserc
├── .vscode
├── extensions.json
└── settings.json
├── .woodpecker.yaml
├── CODE_OF_CONDUCT.md
├── Dockerfile
├── LICENSE
├── README.md
├── docker-compose.yml
├── docker-entrypoint.sh
├── flake.lock
├── flake.nix
├── package-lock.json
├── package.json
├── prisma
├── .gitignore
├── migrations
│ ├── 20230524131536_init
│ │ └── migration.sql
│ ├── 20230524151822_axewr4et5r6ytu7yiuoi
│ │ └── migration.sql
│ ├── 20230529093724_remove_unused_column_for_guild_credit_setings
│ │ └── migration.sql
│ ├── 20230529131756_add_timestamps_for_cooldowns
│ │ └── migration.sql
│ ├── 20230529133956_add_quotes
│ │ └── migration.sql
│ ├── 20230529135410_added_boolean_for_status_of_quotes
│ │ └── migration.sql
│ └── migration_lock.toml
└── schema.prisma
├── src
├── buttons
│ └── primary
│ │ └── index.ts
├── commands
│ ├── credits
│ │ ├── groups
│ │ │ └── bonus
│ │ │ │ ├── index.ts
│ │ │ │ └── subcommands
│ │ │ │ ├── daily
│ │ │ │ └── index.ts
│ │ │ │ ├── monthly
│ │ │ │ └── index.ts
│ │ │ │ └── weekly
│ │ │ │ └── index.ts
│ │ ├── index.ts
│ │ └── subcommands
│ │ │ ├── balance
│ │ │ └── index.ts
│ │ │ ├── gift
│ │ │ └── index.ts
│ │ │ ├── top
│ │ │ └── index.ts
│ │ │ └── work
│ │ │ ├── index.ts
│ │ │ └── jobs.ts
│ ├── dns
│ │ ├── index.ts
│ │ └── subcommands
│ │ │ └── lookup
│ │ │ └── index.ts
│ ├── fun
│ │ ├── index.ts
│ │ └── subcommands
│ │ │ └── meme
│ │ │ └── index.ts
│ ├── manage
│ │ ├── groups
│ │ │ └── credits
│ │ │ │ ├── index.ts
│ │ │ │ └── subcommands
│ │ │ │ ├── give
│ │ │ │ └── index.ts
│ │ │ │ ├── giveaway
│ │ │ │ └── index.ts
│ │ │ │ ├── set
│ │ │ │ └── index.ts
│ │ │ │ ├── take
│ │ │ │ └── index.ts
│ │ │ │ └── transfer
│ │ │ │ └── index.ts
│ │ └── index.ts
│ ├── moderation
│ │ ├── index.ts
│ │ └── subcommands
│ │ │ └── prune
│ │ │ └── index.ts
│ ├── quotes
│ │ ├── index.ts
│ │ └── subcommands
│ │ │ └── post
│ │ │ └── index.ts
│ ├── reputation
│ │ ├── index.ts
│ │ └── subcommands
│ │ │ ├── check
│ │ │ └── index.ts
│ │ │ └── repute
│ │ │ └── index.ts
│ ├── settings
│ │ ├── index.ts
│ │ └── subcommands
│ │ │ ├── credits
│ │ │ └── index.ts
│ │ │ ├── ctrlpanel
│ │ │ └── index.ts
│ │ │ └── quotes
│ │ │ └── index.ts
│ ├── shop
│ │ ├── index.ts
│ │ └── subcommands
│ │ │ └── ctrlpanel.ts
│ └── utils
│ │ ├── index.ts
│ │ └── subcommands
│ │ ├── about
│ │ └── index.ts
│ │ └── avatar
│ │ └── index.ts
├── events
│ ├── guildCreate
│ │ └── index.ts
│ ├── guildDelete
│ │ └── index.ts
│ ├── guildMemberAdd
│ │ └── index.ts
│ ├── guildMemberRemove
│ │ └── index.ts
│ ├── interactionCreate
│ │ ├── index.ts
│ │ └── interactionTypes
│ │ │ ├── button
│ │ │ └── index.ts
│ │ │ └── handleCommandInteraction
│ │ │ ├── handlers
│ │ │ ├── handleCooldown.ts
│ │ │ └── handleUnavailableCommand.ts
│ │ │ └── index.ts
│ ├── messageCreate
│ │ ├── components
│ │ │ └── earnCredits.ts
│ │ └── index.ts
│ ├── rateLimit
│ │ └── index.ts
│ └── ready
│ │ ├── importOldData.ts
│ │ └── index.ts
├── handlers
│ ├── CooldownManager.ts
│ ├── CreditsManager.ts
│ ├── ReputationManager.ts
│ ├── executeSubcommand.ts
│ ├── handleGuildMemberJoin.ts
│ ├── interactionErrorHandler.ts
│ ├── prisma.ts
│ ├── registerCommands.ts
│ ├── registerEvents.ts
│ ├── scheduleJobs.ts
│ └── updatePresence.ts
├── helpers
│ ├── generateCooldownName.ts
│ ├── upsertApiCredentials.ts
│ └── upsertGuildMember.ts
├── index.ts
├── interfaces
│ ├── Command.ts
│ ├── Event.ts
│ ├── EventOptions.ts
│ └── Job.ts
├── jobs
│ └── updatePresence.ts
├── services
│ └── CtrlPanelAPI.ts
├── types
│ └── common
│ │ ├── discord.d.ts
│ │ └── environment.d.ts
└── utils
│ ├── checkPermission.ts
│ ├── deferReply.ts
│ ├── encryption.ts
│ ├── errors.ts
│ ├── logger.ts
│ ├── readDirectory.ts
│ └── sendResponse.ts
└── tsconfig.json
/.cspell/custom-dictionary-workspace.txt:
--------------------------------------------------------------------------------
1 | # Custom Dictionary Words
2 | Controlpanel
3 | cooldown
4 | Cooldowns
5 | cpgg
6 | ctrlpanel
7 | dagen
8 | discordjs
9 | Följande
10 | Gåva
11 | gett
12 | Globalt
13 | hoster
14 | inom
15 | inställningar
16 | inte
17 | Krediter
18 | multistream
19 | nastyox
20 | Nivå
21 | omdöme
22 | Omdöme
23 | Otillgänglig
24 | pino
25 | Poäng
26 | Profil
27 | rando
28 | Repliable
29 | satta
30 | senaste
31 | Sifell
32 | själv
33 | Språk
34 | Språkkod
35 | upsert
36 | uuidv
37 | Vermium
38 | voca
39 | xyter
40 | Zyner
41 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | .vscode
2 | .husky
3 | .github
4 | .cspell
5 | .env
6 | node_modules
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # Do not use "around your text" ("")
2 |
3 | # Timezone
4 | TZ=Europe/Stockholm
5 |
6 | # Do not touch unless you know what you doing
7 | PUID=1000
8 | PGID=1000
9 |
10 | # Discord
11 | DISCORD_TOKEN=
12 | DISCORD_CLIENT_ID=
13 | DISCORD_GUILD_ID=
14 |
15 | # Database
16 | MYSQL_ROOT_PASSWORD=root
17 |
18 | MYSQL_HOST=localhost
19 | MYSQL_USER=username
20 | MYSQL_PASSWORD=password
21 | MYSQL_DATABASE=database
22 |
23 | # DO NOT TOUCH UNLESS YOU KNOW WHAT YOU ARE DOING
24 | DATABASE_URL=mysql://${MYSQL_USER}:${MYSQL_PASSWORD}@${MYSQL_HOST}/${MYSQL_DATABASE}
25 |
26 | # Encryption
27 | ENCRYPTION_ALGORITHM=aes-256-ctr
28 | ENCRYPTION_SECRET=RANDOM STRING WITH STRICTLY 32 IN LENGTH
29 |
30 | #Embed
31 | EMBED_COLOR_SUCCESS=#22bb33
32 | EMBED_COLOR_WAIT=#f0ad4e
33 | EMBED_COLOR_ERROR=#bb2124
34 | EMBED_FOOTER_TEXT=https://github.com/ZynerOrg/xyter
35 | EMBED_FOOTER_ICON=https://github.com/ZynerOrg.png
36 |
37 | # Log
38 | LOG_LEVEL=info
39 |
40 | # Reputation
41 | REPUTATION_TIMEOUT=86400
42 |
43 | # Bot Hoster
44 | BOT_HOSTER_NAME=Zyner
45 | BOT_HOSTER_URL=https://xyter.zyner.org/customization/change-hoster
46 |
--------------------------------------------------------------------------------
/.envrc:
--------------------------------------------------------------------------------
1 | use flake
2 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "parser": "@typescript-eslint/parser",
4 | "plugins": ["@typescript-eslint", "no-loops", "prettier"],
5 | "extends": [
6 | "eslint:recommended",
7 | "plugin:@typescript-eslint/eslint-recommended",
8 | "plugin:@typescript-eslint/recommended",
9 | "prettier"
10 | ],
11 | "rules": {
12 | "no-console": 1,
13 | "no-loops/no-loops": 2
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | json.sqlite
2 | node_modules
3 | .env
4 | db/
5 |
6 | # Build
7 | build/
8 |
9 |
10 | # Logs
11 | logs
12 | *.log
13 | npm-debug.log*
14 | yarn-debug.log*
15 | yarn-error.log*
16 | lerna-debug.log*
17 | .pnpm-debug.log*
18 |
19 | # Diagnostic reports (https://nodejs.org/api/report.html)
20 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
21 |
22 | # Runtime data
23 | pids
24 | *.pid
25 | *.seed
26 | *.pid.lock
27 |
28 | # Directory for instrumented libs generated by jscoverage/JSCover
29 | lib-cov
30 |
31 | # Coverage directory used by tools like istanbul
32 | coverage
33 | *.lcov
34 |
35 | # nyc test coverage
36 | .nyc_output
37 |
38 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
39 | .grunt
40 |
41 | # Bower dependency directory (https://bower.io/)
42 | bower_components
43 |
44 | # node-waf configuration
45 | .lock-wscript
46 |
47 | # Compiled binary addons (https://nodejs.org/api/addons.html)
48 | build/Release
49 |
50 | # Dependency directories
51 | node_modules/
52 | jspm_packages/
53 |
54 | # Snowpack dependency directory (https://snowpack.dev/)
55 | web_modules/
56 |
57 | # TypeScript cache
58 | *.tsbuildinfo
59 |
60 | # Optional npm cache directory
61 | .npm
62 |
63 | # Optional eslint cache
64 | .eslintcache
65 |
66 | # Optional stylelint cache
67 | .stylelintcache
68 |
69 | # Microbundle cache
70 | .rpt2_cache/
71 | .rts2_cache_cjs/
72 | .rts2_cache_es/
73 | .rts2_cache_umd/
74 |
75 | # Optional REPL history
76 | .node_repl_history
77 |
78 | # Output of 'npm pack'
79 | *.tgz
80 |
81 | # Yarn Integrity file
82 | .yarn-integrity
83 |
84 | # dotenv environment variable files
85 | .env
86 | .env.development.local
87 | .env.test.local
88 | .env.production.local
89 | .env.local
90 |
91 | # parcel-bundler cache (https://parceljs.org/)
92 | .cache
93 | .parcel-cache
94 |
95 | # Next.js build output
96 | .next
97 | out
98 |
99 | # Nuxt.js build / generate output
100 | .nuxt
101 | dist
102 |
103 | # Gatsby files
104 | .cache/
105 | # Comment in the public line in if your project uses Gatsby and not Next.js
106 | # https://nextjs.org/blog/next-9-1#public-directory-support
107 | # public
108 |
109 | # vuepress build output
110 | .vuepress/dist
111 |
112 | # vuepress v2.x temp and cache directory
113 | .temp
114 | .cache
115 |
116 | # Docusaurus cache and generated files
117 | .docusaurus
118 |
119 | # Serverless directories
120 | .serverless/
121 |
122 | # FuseBox cache
123 | .fusebox/
124 |
125 | # DynamoDB Local files
126 | .dynamodb/
127 |
128 | # TernJS port file
129 | .tern-port
130 |
131 | # Stores VSCode versions used for testing VSCode extensions
132 | .vscode-test
133 |
134 | # yarn v2
135 | .yarn/cache
136 | .yarn/unplugged
137 | .yarn/build-state.yml
138 | .yarn/install-state.gz
139 | .pnp.*
140 |
141 | # NixOS
142 | .direnv
143 |
144 | # Docker
145 | docker-compose.override.yml
146 |
--------------------------------------------------------------------------------
/.gitlab-ci.yml:
--------------------------------------------------------------------------------
1 | stages:
2 | - build
3 | - docker
4 |
5 | variables:
6 | NODE_VERSION: "20"
7 |
8 | cache:
9 | key: "$CI_COMMIT_REF_SLUG"
10 | paths:
11 | - ~/.npm/
12 |
13 | build:
14 | stage: build
15 | image: alpine:latest
16 | before_script:
17 | - apk update && apk add git tar nodejs npm
18 | - npm ci
19 | script:
20 | - npm run build
21 | - npx semantic-release
22 | - find . -not -path "./node_modules/*" -type f -print0 | xargs -0 tar -czvf build-artifacts.tar.gz
23 | artifacts:
24 | paths:
25 | - build-artifacts.tar.gz
26 | only:
27 | - merge_requests
28 | - pushes
29 | - web
30 |
31 | docker:
32 | stage: docker
33 | dependencies:
34 | - build
35 | image:
36 | name: gcr.io/kaniko-project/executor:v1.14.0-debug
37 | entrypoint: [""]
38 | script:
39 | - tar -xzvf build-artifacts.tar.gz
40 | - |
41 | # If pipeline runs on the default branch: Set tag to "latest"
42 | if test "$CI_COMMIT_BRANCH" == "$CI_DEFAULT_BRANCH"; then
43 | tag="latest"
44 | # If pipeline is a tag pipeline, set tag to the git commit tag
45 | elif test -n "$CI_COMMIT_TAG"; then
46 | tag="$CI_COMMIT_TAG"
47 | # Else set the tag to the git commit sha
48 | else
49 | tag="$CI_COMMIT_SHA"
50 | fi
51 | - |
52 | - /kaniko/executor
53 | --context "${CI_PROJECT_DIR}"
54 | --dockerfile "${CI_PROJECT_DIR}/Dockerfile"
55 | --destination "${CI_REGISTRY_IMAGE}:${tag}"
56 | # only:
57 | # - tags
58 |
--------------------------------------------------------------------------------
/.releaserc:
--------------------------------------------------------------------------------
1 | {
2 | "repositoryUrl": "git@git.zyner.org:meta/xyter.git",
3 | "branches": [
4 | "main",
5 | {
6 | "name": "next",
7 | "prerelease": true
8 | },
9 | {
10 | "name": "dev",
11 | "prerelease": true
12 | }
13 | ],
14 | "debug": true,
15 | "ci": true,
16 | "dryRun": false,
17 | "plugins": [
18 | "@semantic-release/commit-analyzer",
19 | "@semantic-release/release-notes-generator",
20 | "@semantic-release/npm",
21 | [
22 | "@semantic-release/git",
23 | {
24 | "message": "chore(release): ${nextRelease.version}\n\n${nextRelease.notes}"
25 | }
26 | ]
27 | ]
28 | }
29 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.
3 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp
4 | // List of extensions which should be recommended for users of this workspace.
5 | "recommendations": [
6 | "nicoespeon.abracadabra",
7 | "mgmcdermott.vscode-language-babel",
8 | "aaron-bond.better-comments",
9 | "streetsidesoftware.code-spell-checker",
10 | "adpyke.codesnap",
11 | "mikestead.dotenv",
12 | "mateuszdrewniak.theme-dracula-dark-plus",
13 | "irongeek.vscode-env",
14 | "dbaeumer.vscode-eslint",
15 | "dracula-theme.theme-dracula",
16 | "mhutchie.git-graph",
17 | "donjayamanne.githistory",
18 | "github.github-vscode-theme",
19 | "eamodio.gitlens",
20 | "xabikos.javascriptsnippets",
21 | "christian-kohler.npm-intellisense",
22 | "christian-kohler.path-intellisense",
23 | "johnpapa.vscode-peacock",
24 | "esbenp.prettier-vscode",
25 | "wayou.vscode-todo-highlight",
26 | "gruntfuggly.todo-tree",
27 | "pflannery.vscode-versionlens",
28 | "vscode-icons-team.vscode-icons",
29 | "vivaxy.vscode-conventional-commits",
30 | "prisma.prisma"
31 | ],
32 | // List of extensions recommended by VS Code that should not be recommended for users of this workspace.
33 | "unwantedRecommendations": []
34 | }
35 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.bracketPairColorization.enabled": true,
3 | "editor.cursorBlinking": "phase",
4 | "editor.cursorSmoothCaretAnimation": "on",
5 | "editor.defaultFormatter": "esbenp.prettier-vscode",
6 | "editor.fontLigatures": true,
7 | "editor.formatOnSave": true,
8 | "editor.guides.bracketPairs": "active",
9 | "editor.minimap.maxColumn": 200,
10 | "editor.minimap.renderCharacters": false,
11 | "editor.minimap.showSlider": "always",
12 | "editor.tabSize": 2,
13 | // "git.enableCommitSigning": true,
14 | "editor.wordWrapColumn": 100,
15 | "files.eol": "\n",
16 | "files.trimTrailingWhitespace": true,
17 | "editor.codeActionsOnSave": {
18 | "source.organizeImports": true,
19 | "source.formatDocument": true,
20 | "source.fixAll.eslint": true
21 | },
22 | "cSpell.customDictionaries": {
23 | "custom-dictionary-workspace": {
24 | "name": "custom-dictionary-workspace",
25 | "path": "${workspaceFolder:xyter}/.cspell/custom-dictionary-workspace.txt",
26 | "addWords": true,
27 | "scope": "workspace"
28 | }
29 | },
30 | "[dotenv]": {
31 | "editor.defaultFormatter": "IronGeek.vscode-env"
32 | },
33 | "[prisma]": {
34 | "editor.defaultFormatter": "Prisma.prisma"
35 | },
36 | "conventionalCommits.scopes": ["git", "github", "prisma"],
37 | "[dockerfile]": {
38 | "editor.defaultFormatter": "foxundermoon.shell-format"
39 | },
40 | "[sql]": {
41 | "editor.defaultFormatter": "sqlfluff.vscode-sqlfluff"
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/.woodpecker.yaml:
--------------------------------------------------------------------------------
1 | when:
2 | - event: [ push, pull_request, tag ]
3 |
4 | steps:
5 | - name: tags
6 | image: ghcr.io/dvjn/woodpecker-docker-tags-plugin
7 | settings:
8 | tags: |
9 | edge -v latest
10 | edge -b main -v main
11 | edge -b dev -v dev
12 | edge -b next -v next
13 | cron
14 | pr
15 | semver --format {{major}}
16 | semver --format {{major}}.{{minor}}
17 | semver --format {{version}}
18 | sha
19 | - name: build
20 | image: quay.io/woodpeckerci/plugin-kaniko
21 | settings:
22 | registry: reg.zyner.org
23 | repo: library/xyter
24 | username:
25 | from_secret: registry-username
26 | password:
27 | from_secret: registry-password
28 | - name: semantic-release
29 | image: node:20
30 | commands:
31 | - echo "$DEPLOY_KEY" > /tmp/git_deploy_key
32 | - echo >> /tmp/git_deploy_key
33 | - chmod 600 /tmp/git_deploy_key
34 | - mkdir -p $HOME/.ssh
35 | - ssh-keyscan git.zyner.org > $HOME/.ssh/known_hosts
36 | - eval "$(ssh-agent -s)"
37 | - ssh-add /tmp/git_deploy_key
38 | - npm install
39 | - npx semantic-release
40 | environment:
41 | DEPLOY_KEY:
42 | from_secret: deploy-key
43 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | We as members, contributors, and leaders pledge to make participation in our
6 | community a harassment-free experience for everyone, regardless of age, body
7 | size, visible or invisible disability, ethnicity, sex characteristics, gender
8 | identity and expression, level of experience, education, socio-economic status,
9 | nationality, personal appearance, race, religion, or sexual identity
10 | and orientation.
11 |
12 | We pledge to act and interact in ways that contribute to an open, welcoming,
13 | diverse, inclusive, and healthy community.
14 |
15 | ## Our Standards
16 |
17 | Examples of behavior that contributes to a positive environment for our
18 | community include:
19 |
20 | - Demonstrating empathy and kindness toward other people
21 | - Being respectful of differing opinions, viewpoints, and experiences
22 | - Giving and gracefully accepting constructive feedback
23 | - Accepting responsibility and apologizing to those affected by our mistakes,
24 | and learning from the experience
25 | - Focusing on what is best not just for us as individuals, but for the
26 | overall community
27 |
28 | Examples of unacceptable behavior include:
29 |
30 | - The use of sexualized language or imagery, and sexual attention or
31 | advances of any kind
32 | - Trolling, insulting or derogatory comments, and personal or political attacks
33 | - Public or private harassment
34 | - Publishing others' private information, such as a physical or email
35 | address, without their explicit permission
36 | - Other conduct which could reasonably be considered inappropriate in a
37 | professional setting
38 |
39 | ## Enforcement Responsibilities
40 |
41 | Community leaders are responsible for clarifying and enforcing our standards of
42 | acceptable behavior and will take appropriate and fair corrective action in
43 | response to any behavior that they deem inappropriate, threatening, offensive,
44 | or harmful.
45 |
46 | Community leaders have the right and responsibility to remove, edit, or reject
47 | comments, commits, code, wiki edits, issues, and other contributions that are
48 | not aligned to this Code of Conduct, and will communicate reasons for moderation
49 | decisions when appropriate.
50 |
51 | ## Scope
52 |
53 | This Code of Conduct applies within all community spaces, and also applies when
54 | an individual is officially representing the community in public spaces.
55 | Examples of representing our community include using an official e-mail address,
56 | posting via an official social media account, or acting as an appointed
57 | representative at an online or offline event.
58 |
59 | ## Enforcement
60 |
61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
62 | reported to the community leaders responsible for enforcement at
63 | vermium@zyner.org.
64 | All complaints will be reviewed and investigated promptly and fairly.
65 |
66 | All community leaders are obligated to respect the privacy and security of the
67 | reporter of any incident.
68 |
69 | ## Enforcement Guidelines
70 |
71 | Community leaders will follow these Community Impact Guidelines in determining
72 | the consequences for any action they deem in violation of this Code of Conduct:
73 |
74 | ### 1. Correction
75 |
76 | **Community Impact**: Use of inappropriate language or other behavior deemed
77 | unprofessional or unwelcome in the community.
78 |
79 | **Consequence**: A private, written warning from community leaders, providing
80 | clarity around the nature of the violation and an explanation of why the
81 | behavior was inappropriate. A public apology may be requested.
82 |
83 | ### 2. Warning
84 |
85 | **Community Impact**: A violation through a single incident or series
86 | of actions.
87 |
88 | **Consequence**: A warning with consequences for continued behavior. No
89 | interaction with the people involved, including unsolicited interaction with
90 | those enforcing the Code of Conduct, for a specified period of time. This
91 | includes avoiding interactions in community spaces as well as external channels
92 | like social media. Violating these terms may lead to a temporary or
93 | permanent ban.
94 |
95 | ### 3. Temporary Ban
96 |
97 | **Community Impact**: A serious violation of community standards, including
98 | sustained inappropriate behavior.
99 |
100 | **Consequence**: A temporary ban from any sort of interaction or public
101 | communication with the community for a specified period of time. No public or
102 | private interaction with the people involved, including unsolicited interaction
103 | with those enforcing the Code of Conduct, is allowed during this period.
104 | Violating these terms may lead to a permanent ban.
105 |
106 | ### 4. Permanent Ban
107 |
108 | **Community Impact**: Demonstrating a pattern of violation of community
109 | standards, including sustained inappropriate behavior, harassment of an
110 | individual, or aggression toward or disparagement of classes of individuals.
111 |
112 | **Consequence**: A permanent ban from any sort of public interaction within
113 | the community.
114 |
115 | ## Attribution
116 |
117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118 | version 2.0, available at
119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
120 |
121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct
122 | enforcement ladder](https://github.com/mozilla/diversity).
123 |
124 | [homepage]: https://www.contributor-covenant.org
125 |
126 | For answers to common questions about this code of conduct, see the FAQ at
127 | https://www.contributor-covenant.org/faq. Translations are available at
128 | https://www.contributor-covenant.org/translations.
129 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Dependencies
2 | FROM node:20-alpine3.21 AS dependencies
3 |
4 | WORKDIR /app
5 | COPY package.json package-lock.json ./
6 | RUN npm install
7 |
8 | # Build
9 | FROM node:20-alpine3.21 AS build
10 |
11 | WORKDIR /app
12 | COPY --from=dependencies /app/node_modules ./node_modules
13 | COPY . .
14 |
15 | RUN npx prisma generate && npm run build
16 |
17 | # Deploy
18 | FROM node:20-alpine3.21 as deploy
19 |
20 | WORKDIR /app
21 |
22 | ENV NODE_ENV production
23 |
24 | # Add mysql precheck
25 | RUN apk add --no-cache mysql-client
26 | COPY docker-entrypoint.sh /docker-entrypoint.sh
27 | RUN chmod +x /docker-entrypoint.sh
28 |
29 | # Copy files
30 | COPY --from=build /app/package.json ./
31 | COPY --from=build /app/node_modules ./node_modules
32 | COPY --from=build /app/prisma ./prisma
33 | COPY --from=build /app/dist ./dist
34 |
35 | ENTRYPOINT [ "/docker-entrypoint.sh" ]
36 | CMD [ "npm", "run", "start:migrate:prod" ]
37 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | Xyter
4 |
5 |
6 |
7 |
8 |
9 | Please use git.zyner.org instead for collaboration, this is due to mirroring!
10 |
11 |
12 |
13 | A multi purpose Discord bot written in Typescript with Discord.js that tries to respects your privacy by making features opt-in.
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | Overview
25 | •
26 | Installation
27 | •
28 | Documentation
29 | •
30 | Community
31 | •
32 | License
33 |
34 |
35 | # Overview
36 |
37 | Xyter is a privacy-focused bot - by making a lot of features opt-in and can easily be enabled/disabled by server owners. We are not and will never be one of those who makes proprietary code and making money out of it, we are happily accepting donations to make Xyter even better. And of course, you can self-host this bot or use our cloud instance if you want. We are happily helping you setting up your own instance and we provide same support regardless of if you self-host it or if we host it.
38 |
39 | [Installation](#installation) is easy, bot is only supported officially when hosted on **Linux**, if you don't have much Linux knowledge we are happy to help you out on our [Discord server](https://s.zyner.org/discord)! When you have installed the bot you manage settings from within Discord aside from global settings.
40 |
41 | **The default set of modules includes and is not limited to:**
42 |
43 | - Credits (balance,gift,top,work including administrative commands such as give,take,set,transfer)
44 | - Counters (allows you to create channels that only allows a specific word to be sent, user needs to wait until someone else has sent before sending it again)
45 | - Fun (currently only includes meme)
46 | - Shop (allows users to purchase with their credits, custom roles in the discord server and server hosting via [Ctrlpanel.gg](https://ctrlpanel.gg/) if the server has enabled and is hosting their own hosting solution)
47 | - Moderation (currently only includes a prune command, this is because we don't want to implement more damaging commands yet until we have a better solution for permissions)
48 | - Reputation (allows users to give each other a reputation anonymously, this is global which means all users will have their reputation shown across all server the bot instance is connected to)
49 |
50 | **Additionally, other [commands](https://xyter.zyner.org/docs/commands) can easily be found on our documentation site.**
51 |
52 | # Installation
53 |
54 | **The following platforms are officially supported:**
55 |
56 | - [Docker](https://xyter.zyner.org/docs/flavors/on-premise/docker)
57 | - [Node](https://xyter.zyner.org/docs/flavors/on-premise/node)
58 | - [Pterodactyl](https://xyter.zyner.org/docs/flavors/on-premise/pterodactyl)
59 |
60 | If after reading the guide you are still experiencing issues, feel free to join the
61 | [Official Discord Server](https://s.zyner.org/discord) and ask in the **#support** channel for help.
62 |
63 | # Join the community!
64 |
65 | **Xyter** is in continuous development, and is currently still in **beta**!
66 |
67 | Join us on our [Official Discord Server](https://s.zyner.org/discord)!
68 |
69 | # License
70 |
71 | Released under the [GNU GPL v3](https://www.gnu.org/licenses/gpl-3.0.en.html) license.
72 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | xyter:
3 | container_name: xyter
4 | image: zyner/xyter
5 | #build:
6 | # context: .
7 | restart: unless-stopped
8 | env_file:
9 | - .env
10 | volumes:
11 | - ./logs:/logs
12 | depends_on:
13 | - mariadb
14 |
15 | phpmyadmin:
16 | container_name: phpmyadmin
17 | image: phpmyadmin:5
18 | restart: unless-stopped
19 | ports:
20 | - 8080:80
21 | environment:
22 | - PMA_HOST=mariadb
23 | depends_on:
24 | - mariadb
25 |
26 | mariadb:
27 | container_name: mariadb
28 | image: lscr.io/linuxserver/mariadb:latest
29 | restart: unless-stopped
30 | ports:
31 | - 3306:3306
32 | volumes:
33 | - ./db:/config
34 | env_file:
35 | - .env
36 |
--------------------------------------------------------------------------------
/docker-entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | check_db_connection() {
4 | mysqladmin ping -h "${DB_HOST}" -u "${DB_USER}" -p"${DB_PASSWORD}" 2>/dev/null
5 | }
6 |
7 | wait_for_db() {
8 | echo "Checking database connection..."
9 | until check_db_connection; do
10 | echo "Waiting for the database..."
11 | sleep 1
12 | done
13 | echo "Database is ready!"
14 | }
15 |
16 | # Parse the DATABASE_URL into individual variables
17 | DB_URL="${DATABASE_URL#*://}"
18 | DB_USER="${DB_URL%%:*}"
19 | DB_URL="${DB_URL#*:}"
20 | DB_PASSWORD="${DB_URL%%@*}"
21 | DB_HOST="${DB_URL#*@}"
22 | DB_HOST="${DB_HOST%%/*}"
23 | DB_NAME="${DB_URL#*/}"
24 |
25 | wait_for_db
26 | "$@"
27 |
--------------------------------------------------------------------------------
/flake.lock:
--------------------------------------------------------------------------------
1 | {
2 | "nodes": {
3 | "flake-utils": {
4 | "locked": {
5 | "lastModified": 1667395993,
6 | "narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=",
7 | "owner": "numtide",
8 | "repo": "flake-utils",
9 | "rev": "5aed5285a952e0b949eb3ba02c12fa4fcfef535f",
10 | "type": "github"
11 | },
12 | "original": {
13 | "owner": "numtide",
14 | "repo": "flake-utils",
15 | "type": "github"
16 | }
17 | },
18 | "nixpkgs": {
19 | "locked": {
20 | "lastModified": 1674236650,
21 | "narHash": "sha256-B4GKL1YdJnII6DQNNJ4wDW1ySJVx2suB1h/v4Ql8J0Q=",
22 | "owner": "nixos",
23 | "repo": "nixpkgs",
24 | "rev": "cfb43ad7b941d9c3606fb35d91228da7ebddbfc5",
25 | "type": "github"
26 | },
27 | "original": {
28 | "owner": "nixos",
29 | "ref": "nixpkgs-unstable",
30 | "repo": "nixpkgs",
31 | "type": "github"
32 | }
33 | },
34 | "root": {
35 | "inputs": {
36 | "flake-utils": "flake-utils",
37 | "nixpkgs": "nixpkgs"
38 | }
39 | }
40 | },
41 | "root": "root",
42 | "version": 7
43 | }
44 |
--------------------------------------------------------------------------------
/flake.nix:
--------------------------------------------------------------------------------
1 | {
2 | description = "A Nix-flake-based Node.js development environment for Xyter";
3 |
4 | inputs = {
5 | flake-utils.url = "github:numtide/flake-utils";
6 | nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
7 | };
8 |
9 | outputs = { self, flake-utils, nixpkgs }:
10 | flake-utils.lib.eachDefaultSystem (system:
11 | let
12 | overlays = [ (self: super: rec { nodejs = super.nodejs-19_x; }) ];
13 | pkgs = import nixpkgs { inherit overlays system; };
14 | in {
15 | devShells.default = pkgs.mkShell {
16 | buildInputs = with pkgs; [
17 | node2nix
18 | docker
19 | nodejs
20 |
21 | # Node Packages
22 | nodePackages.typescript
23 | nodePackages.prisma
24 | ];
25 | shellHook = with pkgs; ''
26 | export PRISMA_MIGRATION_ENGINE_BINARY="${prisma-engines}/bin/migration-engine"
27 | export PRISMA_QUERY_ENGINE_BINARY="${prisma-engines}/bin/query-engine"
28 | export PRISMA_QUERY_ENGINE_LIBRARY="${prisma-engines}/lib/libquery_engine.node"
29 | export PRISMA_INTROSPECTION_ENGINE_BINARY="${prisma-engines}/bin/introspection-engine"
30 | export PRISMA_FMT_BINARY="${prisma-engines}/bin/prisma-fmt"
31 | '';
32 | };
33 | });
34 | }
35 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "xyter",
3 | "version": "2.8.0",
4 | "private": true,
5 | "description": "A multi purpose Discord bot written in TypeScript with Discord.js",
6 | "main": "dist/index.js",
7 | "scripts": {
8 | "dev": "tsc --watch & NODE_ENV=development nodemon dist",
9 | "build": "tsc -p .",
10 | "prisma:generate": "prisma generate",
11 | "test": "jest",
12 | "start": "node dist",
13 | "start:migrate:prod": "prisma migrate deploy && npm run start",
14 | "prettier-format": "prettier 'src/**/*.ts' --write",
15 | "lint": "eslint ./src --ext .ts"
16 | },
17 | "keywords": [
18 | "Zyner",
19 | "xyter",
20 | "controlpanel",
21 | "controlpanel.gg"
22 | ],
23 | "repository": {
24 | "type": "git",
25 | "url": "git+https://git.zyner.org/meta/xyter.git"
26 | },
27 | "author": "Vermium Sifell (https://zyner.org)",
28 | "contributors": [
29 | "Joshua Schmitt (https://jqshuv.xyz)"
30 | ],
31 | "license": "GPL-3.0-only",
32 | "bugs": {
33 | "url": "https://github.com/ZynerOrg/xyter/issues",
34 | "email": "vermium@zyner.org"
35 | },
36 | "dependencies": {
37 | "@prisma/client": "^6.0.1",
38 | "@semantic-release/gitlab": "^12.0.3",
39 | "axios": "^1.4.0",
40 | "chance": "^1.1.9",
41 | "date-fns": "^4.1.0",
42 | "discord.js": "^14.7.1",
43 | "dotenv": "^16.0.3",
44 | "node-schedule": "^2.1.0",
45 | "uuid": "^11.0.3",
46 | "winston": "^3.8.2",
47 | "winston-daily-rotate-file": "^5.0.0"
48 | },
49 | "devDependencies": {
50 | "@semantic-release/git": "^10.0.1",
51 | "@semantic-release/release-notes-generator": "^12.1.0",
52 | "@types/chance": "1.1.6",
53 | "@types/node-schedule": "2.1.7",
54 | "@types/uuid": "^10.0.0",
55 | "@typescript-eslint/eslint-plugin": "^8.18.0",
56 | "@typescript-eslint/parser": "^8.18.0",
57 | "eslint": "^9.16.0",
58 | "eslint-config-prettier": "^9.1.0",
59 | "eslint-plugin-import": "^2.27.5",
60 | "eslint-plugin-no-loops": "0.4.0",
61 | "eslint-plugin-prettier": "5.2.1",
62 | "lint-staged": "^15.2.11",
63 | "nodemon": "^3.1.7",
64 | "prettier": "^3.4.2",
65 | "prisma": "^6.0.1",
66 | "semantic-release": "^24.2.3",
67 | "typescript": "^5.0.4"
68 | },
69 | "lint-staged": {
70 | "*.ts": "eslint --cache --fix"
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/prisma/.gitignore:
--------------------------------------------------------------------------------
1 | *.db
2 | *.db-journal
3 |
--------------------------------------------------------------------------------
/prisma/migrations/20230524131536_init/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE `Guild` (
3 | `id` VARCHAR(191) NOT NULL,
4 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
5 | `updatedAt` DATETIME(3) NOT NULL,
6 |
7 | UNIQUE INDEX `Guild_id_key`(`id`)
8 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
9 |
10 | -- CreateTable
11 | CREATE TABLE `User` (
12 | `id` VARCHAR(191) NOT NULL,
13 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
14 | `updatedAt` DATETIME(3) NOT NULL,
15 |
16 | UNIQUE INDEX `User_id_key`(`id`)
17 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
18 |
19 | -- CreateTable
20 | CREATE TABLE `GuildMember` (
21 | `guildId` VARCHAR(191) NOT NULL,
22 | `userId` VARCHAR(191) NOT NULL,
23 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
24 | `updatedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
25 |
26 | UNIQUE INDEX `GuildMember_guildId_userId_key`(`guildId`, `userId`)
27 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
28 |
29 | -- CreateTable
30 | CREATE TABLE `GuildMemberCredit` (
31 | `guildId` VARCHAR(191) NOT NULL,
32 | `userId` VARCHAR(191) NOT NULL,
33 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
34 | `updatedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
35 | `balance` INTEGER NOT NULL DEFAULT 0,
36 |
37 | UNIQUE INDEX `GuildMemberCredit_guildId_userId_key`(`guildId`, `userId`)
38 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
39 |
40 | -- CreateTable
41 | CREATE TABLE `UserReputation` (
42 | `id` VARCHAR(191) NOT NULL,
43 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
44 | `updatedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
45 | `negative` INTEGER NOT NULL DEFAULT 0,
46 | `positive` INTEGER NOT NULL DEFAULT 0,
47 |
48 | UNIQUE INDEX `UserReputation_id_key`(`id`)
49 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
50 |
51 | -- CreateTable
52 | CREATE TABLE `GuildSettings` (
53 | `id` VARCHAR(191) NOT NULL,
54 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
55 | `updatedAt` DATETIME(3) NOT NULL,
56 | `guildCreditsSettingsId` VARCHAR(191) NULL,
57 |
58 | UNIQUE INDEX `GuildSettings_id_key`(`id`)
59 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
60 |
61 | -- CreateTable
62 | CREATE TABLE `GuildCreditsSettings` (
63 | `id` VARCHAR(191) NOT NULL,
64 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
65 | `updatedAt` DATETIME(3) NOT NULL,
66 | `workBonusChance` INTEGER NOT NULL DEFAULT 30,
67 | `workPenaltyChance` INTEGER NOT NULL DEFAULT 10,
68 | `status` BOOLEAN NOT NULL DEFAULT false,
69 | `rate` INTEGER NOT NULL DEFAULT 1,
70 | `timeout` INTEGER NOT NULL DEFAULT 5,
71 | `workRate` INTEGER NOT NULL DEFAULT 25,
72 | `workTimeout` INTEGER NOT NULL DEFAULT 86400,
73 | `minimumLength` INTEGER NOT NULL DEFAULT 5,
74 |
75 | UNIQUE INDEX `GuildCreditsSettings_id_key`(`id`)
76 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
77 |
78 | -- CreateTable
79 | CREATE TABLE `ApiCredentials` (
80 | `id` VARCHAR(191) NOT NULL,
81 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
82 | `updatedAt` DATETIME(3) NOT NULL,
83 | `guildId` VARCHAR(191) NULL,
84 | `userId` VARCHAR(191) NULL,
85 | `apiName` VARCHAR(191) NOT NULL,
86 | `credentials` JSON NOT NULL,
87 |
88 | UNIQUE INDEX `ApiCredentials_guildId_apiName_key`(`guildId`, `apiName`),
89 | UNIQUE INDEX `ApiCredentials_userId_apiName_key`(`userId`, `apiName`),
90 | PRIMARY KEY (`id`)
91 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
92 |
93 | -- CreateTable
94 | CREATE TABLE `Cooldown` (
95 | `id` INTEGER NOT NULL AUTO_INCREMENT,
96 | `cooldownItem` VARCHAR(191) NOT NULL,
97 | `expiresAt` DATETIME(3) NOT NULL,
98 | `guildId` VARCHAR(191) NULL,
99 | `userId` VARCHAR(191) NULL,
100 |
101 | INDEX `cooldownItem_guildId_idx`(`cooldownItem`, `guildId`),
102 | INDEX `cooldownItem_userId_idx`(`cooldownItem`, `userId`),
103 | INDEX `cooldownItem_guildId_userId_idx`(`cooldownItem`, `guildId`, `userId`),
104 | PRIMARY KEY (`id`)
105 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
106 |
107 | -- CreateTable
108 | CREATE TABLE `ImportOldData` (
109 | `id` VARCHAR(191) NOT NULL,
110 | `done` BOOLEAN NOT NULL DEFAULT false,
111 | `beforeMessageId` VARCHAR(191) NULL,
112 |
113 | UNIQUE INDEX `ImportOldData_id_key`(`id`)
114 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
115 |
116 | -- AddForeignKey
117 | ALTER TABLE `GuildMember` ADD CONSTRAINT `GuildMember_guildId_fkey` FOREIGN KEY (`guildId`) REFERENCES `Guild`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
118 |
119 | -- AddForeignKey
120 | ALTER TABLE `GuildMember` ADD CONSTRAINT `GuildMember_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
121 |
122 | -- AddForeignKey
123 | ALTER TABLE `GuildMemberCredit` ADD CONSTRAINT `GuildMemberCredit_guildId_userId_fkey` FOREIGN KEY (`guildId`, `userId`) REFERENCES `GuildMember`(`guildId`, `userId`) ON DELETE RESTRICT ON UPDATE CASCADE;
124 |
125 | -- AddForeignKey
126 | ALTER TABLE `UserReputation` ADD CONSTRAINT `UserReputation_id_fkey` FOREIGN KEY (`id`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
127 |
128 | -- AddForeignKey
129 | ALTER TABLE `GuildSettings` ADD CONSTRAINT `GuildSettings_id_fkey` FOREIGN KEY (`id`) REFERENCES `Guild`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
130 |
131 | -- AddForeignKey
132 | ALTER TABLE `GuildSettings` ADD CONSTRAINT `GuildSettings_guildCreditsSettingsId_fkey` FOREIGN KEY (`guildCreditsSettingsId`) REFERENCES `GuildCreditsSettings`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
133 |
134 | -- AddForeignKey
135 | ALTER TABLE `GuildCreditsSettings` ADD CONSTRAINT `GuildCreditsSettings_id_fkey` FOREIGN KEY (`id`) REFERENCES `Guild`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
136 |
137 | -- AddForeignKey
138 | ALTER TABLE `ApiCredentials` ADD CONSTRAINT `ApiCredentials_guildId_fkey` FOREIGN KEY (`guildId`) REFERENCES `Guild`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
139 |
140 | -- AddForeignKey
141 | ALTER TABLE `ApiCredentials` ADD CONSTRAINT `ApiCredentials_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
142 |
143 | -- AddForeignKey
144 | ALTER TABLE `ApiCredentials` ADD CONSTRAINT `ApiCredentials_guildId_userId_fkey` FOREIGN KEY (`guildId`, `userId`) REFERENCES `GuildMember`(`guildId`, `userId`) ON DELETE SET NULL ON UPDATE CASCADE;
145 |
146 | -- AddForeignKey
147 | ALTER TABLE `Cooldown` ADD CONSTRAINT `Cooldown_guildId_fkey` FOREIGN KEY (`guildId`) REFERENCES `Guild`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
148 |
149 | -- AddForeignKey
150 | ALTER TABLE `Cooldown` ADD CONSTRAINT `Cooldown_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
151 |
152 | -- AddForeignKey
153 | ALTER TABLE `Cooldown` ADD CONSTRAINT `Cooldown_guildId_userId_fkey` FOREIGN KEY (`guildId`, `userId`) REFERENCES `GuildMember`(`guildId`, `userId`) ON DELETE SET NULL ON UPDATE CASCADE;
154 |
--------------------------------------------------------------------------------
/prisma/migrations/20230524151822_axewr4et5r6ytu7yiuoi/migration.sql:
--------------------------------------------------------------------------------
1 | -- AlterTable
2 | ALTER TABLE `GuildCreditsSettings` ADD COLUMN `dailyBonusAmount` INTEGER NOT NULL DEFAULT 25,
3 | ADD COLUMN `monthlyBonusAmount` INTEGER NOT NULL DEFAULT 150,
4 | ADD COLUMN `weeklyBonusAmount` INTEGER NOT NULL DEFAULT 50;
5 |
--------------------------------------------------------------------------------
/prisma/migrations/20230529093724_remove_unused_column_for_guild_credit_setings/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - You are about to drop the column `minimumLength` on the `GuildCreditsSettings` table. All the data in the column will be lost.
5 | - You are about to drop the column `rate` on the `GuildCreditsSettings` table. All the data in the column will be lost.
6 | - You are about to drop the column `status` on the `GuildCreditsSettings` table. All the data in the column will be lost.
7 | - You are about to drop the column `timeout` on the `GuildCreditsSettings` table. All the data in the column will be lost.
8 | - You are about to drop the column `workRate` on the `GuildCreditsSettings` table. All the data in the column will be lost.
9 | - You are about to drop the column `workTimeout` on the `GuildCreditsSettings` table. All the data in the column will be lost.
10 |
11 | */
12 | -- DropForeignKey
13 | ALTER TABLE `ApiCredentials` DROP FOREIGN KEY `ApiCredentials_guildId_fkey`;
14 |
15 | -- DropForeignKey
16 | ALTER TABLE `ApiCredentials` DROP FOREIGN KEY `ApiCredentials_guildId_userId_fkey`;
17 |
18 | -- DropForeignKey
19 | ALTER TABLE `ApiCredentials` DROP FOREIGN KEY `ApiCredentials_userId_fkey`;
20 |
21 | -- DropForeignKey
22 | ALTER TABLE `Cooldown` DROP FOREIGN KEY `Cooldown_guildId_fkey`;
23 |
24 | -- DropForeignKey
25 | ALTER TABLE `Cooldown` DROP FOREIGN KEY `Cooldown_guildId_userId_fkey`;
26 |
27 | -- DropForeignKey
28 | ALTER TABLE `Cooldown` DROP FOREIGN KEY `Cooldown_userId_fkey`;
29 |
30 | -- DropForeignKey
31 | ALTER TABLE `GuildCreditsSettings` DROP FOREIGN KEY `GuildCreditsSettings_id_fkey`;
32 |
33 | -- DropForeignKey
34 | ALTER TABLE `GuildMember` DROP FOREIGN KEY `GuildMember_guildId_fkey`;
35 |
36 | -- DropForeignKey
37 | ALTER TABLE `GuildMember` DROP FOREIGN KEY `GuildMember_userId_fkey`;
38 |
39 | -- DropForeignKey
40 | ALTER TABLE `GuildMemberCredit` DROP FOREIGN KEY `GuildMemberCredit_guildId_userId_fkey`;
41 |
42 | -- DropForeignKey
43 | ALTER TABLE `GuildSettings` DROP FOREIGN KEY `GuildSettings_guildCreditsSettingsId_fkey`;
44 |
45 | -- DropForeignKey
46 | ALTER TABLE `GuildSettings` DROP FOREIGN KEY `GuildSettings_id_fkey`;
47 |
48 | -- DropForeignKey
49 | ALTER TABLE `UserReputation` DROP FOREIGN KEY `UserReputation_id_fkey`;
50 |
51 | -- AlterTable
52 | ALTER TABLE `GuildCreditsSettings` DROP COLUMN `minimumLength`,
53 | DROP COLUMN `rate`,
54 | DROP COLUMN `status`,
55 | DROP COLUMN `timeout`,
56 | DROP COLUMN `workRate`,
57 | DROP COLUMN `workTimeout`;
58 |
59 | -- CreateIndex
60 | CREATE INDEX `GuildMemberCredit_guildId_idx` ON `GuildMemberCredit`(`guildId`);
61 |
62 | -- CreateIndex
63 | CREATE INDEX `GuildMemberCredit_userId_idx` ON `GuildMemberCredit`(`userId`);
64 |
65 | -- CreateIndex
66 | CREATE INDEX `GuildMemberCredit_guildId_userId_idx` ON `GuildMemberCredit`(`guildId`, `userId`);
67 |
68 | -- AddForeignKey
69 | ALTER TABLE `GuildMember` ADD CONSTRAINT `GuildMember_guildId_fkey` FOREIGN KEY (`guildId`) REFERENCES `Guild`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
70 |
71 | -- AddForeignKey
72 | ALTER TABLE `GuildMember` ADD CONSTRAINT `GuildMember_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
73 |
74 | -- AddForeignKey
75 | ALTER TABLE `GuildMemberCredit` ADD CONSTRAINT `GuildMemberCredit_guildId_userId_fkey` FOREIGN KEY (`guildId`, `userId`) REFERENCES `GuildMember`(`guildId`, `userId`) ON DELETE CASCADE ON UPDATE CASCADE;
76 |
77 | -- AddForeignKey
78 | ALTER TABLE `UserReputation` ADD CONSTRAINT `UserReputation_id_fkey` FOREIGN KEY (`id`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
79 |
80 | -- AddForeignKey
81 | ALTER TABLE `GuildSettings` ADD CONSTRAINT `GuildSettings_id_fkey` FOREIGN KEY (`id`) REFERENCES `Guild`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
82 |
83 | -- AddForeignKey
84 | ALTER TABLE `GuildSettings` ADD CONSTRAINT `GuildSettings_guildCreditsSettingsId_fkey` FOREIGN KEY (`guildCreditsSettingsId`) REFERENCES `GuildCreditsSettings`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
85 |
86 | -- AddForeignKey
87 | ALTER TABLE `GuildCreditsSettings` ADD CONSTRAINT `GuildCreditsSettings_id_fkey` FOREIGN KEY (`id`) REFERENCES `Guild`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
88 |
89 | -- AddForeignKey
90 | ALTER TABLE `ApiCredentials` ADD CONSTRAINT `ApiCredentials_guildId_fkey` FOREIGN KEY (`guildId`) REFERENCES `Guild`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
91 |
92 | -- AddForeignKey
93 | ALTER TABLE `ApiCredentials` ADD CONSTRAINT `ApiCredentials_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
94 |
95 | -- AddForeignKey
96 | ALTER TABLE `ApiCredentials` ADD CONSTRAINT `ApiCredentials_guildId_userId_fkey` FOREIGN KEY (`guildId`, `userId`) REFERENCES `GuildMember`(`guildId`, `userId`) ON DELETE CASCADE ON UPDATE CASCADE;
97 |
98 | -- AddForeignKey
99 | ALTER TABLE `Cooldown` ADD CONSTRAINT `Cooldown_guildId_fkey` FOREIGN KEY (`guildId`) REFERENCES `Guild`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
100 |
101 | -- AddForeignKey
102 | ALTER TABLE `Cooldown` ADD CONSTRAINT `Cooldown_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
103 |
104 | -- AddForeignKey
105 | ALTER TABLE `Cooldown` ADD CONSTRAINT `Cooldown_guildId_userId_fkey` FOREIGN KEY (`guildId`, `userId`) REFERENCES `GuildMember`(`guildId`, `userId`) ON DELETE CASCADE ON UPDATE CASCADE;
106 |
--------------------------------------------------------------------------------
/prisma/migrations/20230529131756_add_timestamps_for_cooldowns/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - The primary key for the `Cooldown` table will be changed. If it partially fails, the table could be left without primary key constraint.
5 | - Added the required column `updatedAt` to the `Cooldown` table without a default value. This is not possible if the table is not empty.
6 |
7 | */
8 | -- AlterTable
9 | ALTER TABLE `Cooldown` DROP PRIMARY KEY,
10 | ADD COLUMN `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
11 | ADD COLUMN `updatedAt` DATETIME(3) NOT NULL,
12 | MODIFY `id` VARCHAR(191) NOT NULL,
13 | ADD PRIMARY KEY (`id`);
14 |
--------------------------------------------------------------------------------
/prisma/migrations/20230529133956_add_quotes/migration.sql:
--------------------------------------------------------------------------------
1 | -- AlterTable
2 | ALTER TABLE `GuildSettings` ADD COLUMN `guildQuotesSettingsId` VARCHAR(191) NULL;
3 |
4 | -- CreateTable
5 | CREATE TABLE `GuildQuotesSettings` (
6 | `id` VARCHAR(191) NOT NULL,
7 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
8 | `updatedAt` DATETIME(3) NOT NULL,
9 | `quoteChannelId` VARCHAR(191) NOT NULL,
10 |
11 | UNIQUE INDEX `GuildQuotesSettings_id_key`(`id`)
12 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
13 |
14 | -- CreateTable
15 | CREATE TABLE `Quotes` (
16 | `id` VARCHAR(191) NOT NULL,
17 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
18 | `updatedAt` DATETIME(3) NOT NULL,
19 | `userId` VARCHAR(191) NOT NULL,
20 | `guildId` VARCHAR(191) NOT NULL,
21 | `message` VARCHAR(191) NOT NULL,
22 | `posterUserId` VARCHAR(191) NOT NULL,
23 |
24 | PRIMARY KEY (`id`)
25 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
26 |
27 | -- AddForeignKey
28 | ALTER TABLE `GuildSettings` ADD CONSTRAINT `GuildSettings_guildQuotesSettingsId_fkey` FOREIGN KEY (`guildQuotesSettingsId`) REFERENCES `GuildQuotesSettings`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
29 |
30 | -- AddForeignKey
31 | ALTER TABLE `GuildQuotesSettings` ADD CONSTRAINT `GuildQuotesSettings_id_fkey` FOREIGN KEY (`id`) REFERENCES `Guild`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
32 |
33 | -- AddForeignKey
34 | ALTER TABLE `Quotes` ADD CONSTRAINT `Quotes_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
35 |
36 | -- AddForeignKey
37 | ALTER TABLE `Quotes` ADD CONSTRAINT `Quotes_guildId_fkey` FOREIGN KEY (`guildId`) REFERENCES `Guild`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
38 |
39 | -- AddForeignKey
40 | ALTER TABLE `Quotes` ADD CONSTRAINT `Quotes_posterUserId_fkey` FOREIGN KEY (`posterUserId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
41 |
--------------------------------------------------------------------------------
/prisma/migrations/20230529135410_added_boolean_for_status_of_quotes/migration.sql:
--------------------------------------------------------------------------------
1 | -- AlterTable
2 | ALTER TABLE `GuildQuotesSettings` ADD COLUMN `status` BOOLEAN NOT NULL DEFAULT false;
3 |
--------------------------------------------------------------------------------
/prisma/migrations/migration_lock.toml:
--------------------------------------------------------------------------------
1 | # Please do not edit this file manually
2 | # It should be added in your version-control system (i.e. Git)
3 | provider = "mysql"
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | generator client {
2 | provider = "prisma-client-js"
3 | binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
4 | }
5 |
6 | datasource db {
7 | provider = "mysql"
8 | url = env("DATABASE_URL")
9 | }
10 |
11 | model Guild {
12 | id String @unique
13 | createdAt DateTime @default(now())
14 | updatedAt DateTime @updatedAt
15 |
16 | guildMembers GuildMember[]
17 | guildSettings GuildSettings?
18 | guildCreditsSettings GuildCreditsSettings?
19 | guildQuotesSettings GuildQuotesSettings?
20 | apiCredentials ApiCredentials[]
21 | cooldowns Cooldown[]
22 | Quotes Quotes[]
23 | }
24 |
25 | model User {
26 | id String @unique
27 | createdAt DateTime @default(now())
28 | updatedAt DateTime @updatedAt
29 | guildMembers GuildMember[]
30 | apiCredentials ApiCredentials[]
31 | cooldowns Cooldown[]
32 |
33 | userReputation UserReputation?
34 | Quotes Quotes[] @relation(name: "Quotes")
35 | PostedQuotes Quotes[] @relation(name: "PostedQuotes")
36 | }
37 |
38 | model GuildMember {
39 | guildId String
40 | userId String
41 | createdAt DateTime @default(now())
42 | updatedAt DateTime @default(now())
43 |
44 | guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
45 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
46 |
47 | guildMemberCredit GuildMemberCredit?
48 | apiCredentials ApiCredentials[]
49 | cooldowns Cooldown[]
50 |
51 | @@unique([guildId, userId])
52 | }
53 |
54 | model GuildMemberCredit {
55 | guildId String
56 | userId String
57 | createdAt DateTime @default(now())
58 | updatedAt DateTime @default(now())
59 |
60 | guildMember GuildMember @relation(fields: [guildId, userId], references: [guildId, userId], onDelete: Cascade)
61 | balance Int @default(0)
62 |
63 | @@unique([guildId, userId])
64 | @@index([guildId])
65 | @@index([userId])
66 | @@index([guildId, userId])
67 | }
68 |
69 | model UserReputation {
70 | id String @unique
71 | createdAt DateTime @default(now())
72 | updatedAt DateTime @default(now())
73 |
74 | user User @relation(fields: [id], references: [id], onDelete: Cascade)
75 |
76 | negative Int @default(0)
77 | positive Int @default(0)
78 | }
79 |
80 | model GuildSettings {
81 | id String @unique
82 | createdAt DateTime @default(now())
83 | updatedAt DateTime @updatedAt
84 |
85 | guild Guild @relation(fields: [id], references: [id], onDelete: Cascade)
86 |
87 | creditsSettings GuildCreditsSettings? @relation(fields: [guildCreditsSettingsId], references: [id], onDelete: Cascade)
88 |
89 | guildCreditsSettingsId String?
90 | GuildQuotesSettings GuildQuotesSettings? @relation(fields: [guildQuotesSettingsId], references: [id])
91 | guildQuotesSettingsId String?
92 | }
93 |
94 | model GuildCreditsSettings {
95 | id String @unique
96 | createdAt DateTime @default(now())
97 | updatedAt DateTime @updatedAt
98 |
99 | guild Guild @relation(fields: [id], references: [id], onDelete: Cascade)
100 |
101 | // Work commands
102 | workBonusChance Int @default(30)
103 | workPenaltyChance Int @default(10)
104 |
105 | // Bonus commands
106 | dailyBonusAmount Int @default(25)
107 | weeklyBonusAmount Int @default(50)
108 | monthlyBonusAmount Int @default(150)
109 |
110 | guildSettings GuildSettings[]
111 | }
112 |
113 | model GuildQuotesSettings {
114 | id String @unique
115 | createdAt DateTime @default(now())
116 | updatedAt DateTime @updatedAt
117 |
118 | guild Guild @relation(fields: [id], references: [id], onDelete: Cascade)
119 |
120 | status Boolean @default(false)
121 | quoteChannelId String
122 |
123 | guildSettings GuildSettings[]
124 | }
125 |
126 | model Quotes {
127 | id String @id @default(uuid())
128 | createdAt DateTime @default(now())
129 | updatedAt DateTime @updatedAt
130 |
131 | user User @relation(fields: [userId], references: [id], name: "Quotes")
132 | userId String
133 |
134 | guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
135 | guildId String
136 |
137 | message String
138 |
139 | posterUserId String
140 | posterUser User @relation(fields: [posterUserId], references: [id], name: "PostedQuotes")
141 | }
142 |
143 | model ApiCredentials {
144 | id String @id @default(cuid())
145 | createdAt DateTime @default(now())
146 | updatedAt DateTime @updatedAt
147 |
148 | guild Guild? @relation(fields: [guildId], references: [id], onDelete: Cascade)
149 | guildId String?
150 |
151 | user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
152 | userId String?
153 |
154 | guildMember GuildMember? @relation(fields: [guildId, userId], references: [guildId, userId], onDelete: Cascade)
155 |
156 | apiName String
157 | credentials Json
158 |
159 | @@unique([guildId, apiName])
160 | @@unique([userId, apiName])
161 | }
162 |
163 | model Cooldown {
164 | id String @id @default(uuid())
165 | createdAt DateTime @default(now())
166 | updatedAt DateTime @updatedAt
167 |
168 | expiresAt DateTime
169 | cooldownItem String
170 |
171 | guild Guild? @relation(fields: [guildId], references: [id], onDelete: Cascade)
172 | user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
173 | guildMember GuildMember? @relation(fields: [guildId, userId], references: [guildId, userId], onDelete: Cascade)
174 |
175 | guildId String?
176 | userId String?
177 |
178 | @@index([cooldownItem, guildId], name: "cooldownItem_guildId_idx")
179 | @@index([cooldownItem, userId], name: "cooldownItem_userId_idx")
180 | @@index([cooldownItem, guildId, userId], name: "cooldownItem_guildId_userId_idx")
181 | }
182 |
183 | model ImportOldData {
184 | id String @unique
185 | done Boolean @default(false)
186 | beforeMessageId String?
187 | }
188 |
--------------------------------------------------------------------------------
/src/buttons/primary/index.ts:
--------------------------------------------------------------------------------
1 | import { ButtonInteraction } from "discord.js";
2 | import logger from "../../utils/logger";
3 |
4 | export const metadata = { guildOnly: false, ephemeral: false };
5 |
6 | // Execute the function
7 | export const execute = (interaction: ButtonInteraction) => {
8 | logger.debug(interaction.customId, "primary button clicked!");
9 | };
10 |
--------------------------------------------------------------------------------
/src/commands/credits/groups/bonus/index.ts:
--------------------------------------------------------------------------------
1 | import { SlashCommandSubcommandGroupBuilder } from "discord.js";
2 | import * as daily from "./subcommands/daily";
3 | import * as monthly from "./subcommands/monthly";
4 | import * as weekly from "./subcommands/weekly";
5 |
6 | export const builder = (group: SlashCommandSubcommandGroupBuilder) => {
7 | return group
8 | .setName("bonus")
9 | .setDescription("Get bonuses")
10 | .addSubcommand(daily.builder)
11 | .addSubcommand(weekly.builder)
12 | .addSubcommand(monthly.builder);
13 | };
14 |
15 | export const subcommands = {
16 | daily,
17 | weekly,
18 | monthly,
19 | };
20 |
--------------------------------------------------------------------------------
/src/commands/credits/groups/bonus/subcommands/daily/index.ts:
--------------------------------------------------------------------------------
1 | import { addDays, startOfDay } from "date-fns";
2 | import {
3 | ChatInputCommandInteraction,
4 | EmbedBuilder,
5 | SlashCommandSubcommandBuilder,
6 | } from "discord.js";
7 | import CooldownManager from "../../../../../../handlers/CooldownManager";
8 | import CreditsManager from "../../../../../../handlers/CreditsManager";
9 | import prisma from "../../../../../../handlers/prisma";
10 | import generateCooldownName from "../../../../../../helpers/generateCooldownName";
11 | import deferReply from "../../../../../../utils/deferReply";
12 | import {
13 | GuildNotFoundError,
14 | UserNotFoundError,
15 | } from "../../../../../../utils/errors";
16 | import sendResponse from "../../../../../../utils/sendResponse";
17 |
18 | const cooldownManager = new CooldownManager();
19 | const creditsManager = new CreditsManager();
20 |
21 | export const builder = (command: SlashCommandSubcommandBuilder) => {
22 | return command.setName("daily").setDescription("Claim your daily treasure!");
23 | };
24 |
25 | export const execute = async (interaction: ChatInputCommandInteraction) => {
26 | const { guild, user } = interaction;
27 |
28 | await deferReply(interaction, false);
29 | if (!guild) throw new GuildNotFoundError();
30 | if (!user) throw new UserNotFoundError();
31 |
32 | const guildCreditsSettings = await prisma.guildCreditsSettings.upsert({
33 | where: { id: guild.id },
34 | update: {},
35 | create: { id: guild.id },
36 | });
37 |
38 | const dailyBonusAmount = guildCreditsSettings.dailyBonusAmount;
39 | const userEconomy = await creditsManager.give(guild, user, dailyBonusAmount);
40 |
41 | const embed = new EmbedBuilder()
42 | .setColor(process.env.EMBED_COLOR_SUCCESS)
43 | .setAuthor({
44 | name: "🌟 Daily Treasure Claimed",
45 | })
46 | .setThumbnail(user.displayAvatarURL())
47 | .setDescription(
48 | `You've just claimed your daily treasure of **${dailyBonusAmount} credits**! 🎉\nEmbark on an epic adventure and spend your riches wisely.\n\n💰 **Your balance**: ${userEconomy.balance} credits`
49 | )
50 | .setFooter({
51 | text: `Claimed by ${user.username}`,
52 | iconURL: user.displayAvatarURL() || "",
53 | })
54 | .setTimestamp();
55 |
56 | await sendResponse(interaction, { embeds: [embed] });
57 |
58 | await cooldownManager.setCooldown(
59 | await generateCooldownName(interaction),
60 | guild,
61 | user,
62 | startOfDay(addDays(new Date(), 1))
63 | );
64 | };
65 |
--------------------------------------------------------------------------------
/src/commands/credits/groups/bonus/subcommands/monthly/index.ts:
--------------------------------------------------------------------------------
1 | import { addMonths, startOfDay } from "date-fns";
2 | import {
3 | ChatInputCommandInteraction,
4 | EmbedBuilder,
5 | SlashCommandSubcommandBuilder,
6 | } from "discord.js";
7 | import CooldownManager from "../../../../../../handlers/CooldownManager";
8 | import CreditsManager from "../../../../../../handlers/CreditsManager";
9 | import prisma from "../../../../../../handlers/prisma";
10 | import generateCooldownName from "../../../../../../helpers/generateCooldownName";
11 | import deferReply from "../../../../../../utils/deferReply";
12 | import {
13 | GuildNotFoundError,
14 | UserNotFoundError,
15 | } from "../../../../../../utils/errors";
16 | import sendResponse from "../../../../../../utils/sendResponse";
17 |
18 | const cooldownManager = new CooldownManager();
19 | const creditsManager = new CreditsManager();
20 |
21 | export const builder = (command: SlashCommandSubcommandBuilder) => {
22 | return command
23 | .setName("monthly")
24 | .setDescription("Claim your monthly treasure!");
25 | };
26 |
27 | export const execute = async (interaction: ChatInputCommandInteraction) => {
28 | const { guild, user } = interaction;
29 |
30 | await deferReply(interaction, false);
31 |
32 | if (!guild) throw new GuildNotFoundError();
33 | if (!user) throw new UserNotFoundError();
34 |
35 | const guildCreditsSettings = await prisma.guildCreditsSettings.upsert({
36 | where: { id: guild.id },
37 | update: {},
38 | create: { id: guild.id },
39 | });
40 |
41 | const monthlyBonusAmount = guildCreditsSettings.monthlyBonusAmount;
42 | const userEconomy = await creditsManager.give(
43 | guild,
44 | user,
45 | monthlyBonusAmount
46 | );
47 |
48 | const embed = new EmbedBuilder()
49 | .setColor(process.env.EMBED_COLOR_SUCCESS)
50 | .setAuthor({
51 | name: "🌟 Monthly Treasure Claimed",
52 | })
53 | .setThumbnail(user.displayAvatarURL())
54 | .setDescription(
55 | `You've just claimed your monthly treasure of **${monthlyBonusAmount} credits**! 🎉\nEmbark on an epic adventure and spend your riches wisely.\n\n💰 **Your balance**: ${userEconomy.balance} credits`
56 | )
57 | .setFooter({
58 | text: `Claimed by ${user.username}`,
59 | iconURL: user.displayAvatarURL() || "",
60 | })
61 | .setTimestamp();
62 |
63 | await sendResponse(interaction, { embeds: [embed] });
64 |
65 | const cooldownDuration = 4 * 7 * 24 * 60 * 60; // 1 month in seconds
66 | const cooldownName = await generateCooldownName(interaction);
67 | await cooldownManager.setCooldown(
68 | await generateCooldownName(interaction),
69 | guild,
70 | user,
71 | startOfDay(addMonths(new Date(), 1))
72 | );
73 | };
74 |
--------------------------------------------------------------------------------
/src/commands/credits/groups/bonus/subcommands/weekly/index.ts:
--------------------------------------------------------------------------------
1 | import { addWeeks, startOfDay } from "date-fns";
2 | import {
3 | ChatInputCommandInteraction,
4 | EmbedBuilder,
5 | SlashCommandSubcommandBuilder,
6 | } from "discord.js";
7 | import CooldownManager from "../../../../../../handlers/CooldownManager";
8 | import CreditsManager from "../../../../../../handlers/CreditsManager";
9 | import prisma from "../../../../../../handlers/prisma";
10 | import generateCooldownName from "../../../../../../helpers/generateCooldownName";
11 | import deferReply from "../../../../../../utils/deferReply";
12 | import {
13 | GuildNotFoundError,
14 | UserNotFoundError,
15 | } from "../../../../../../utils/errors";
16 | import sendResponse from "../../../../../../utils/sendResponse";
17 |
18 | const cooldownManager = new CooldownManager();
19 | const creditsManager = new CreditsManager();
20 |
21 | export const builder = (command: SlashCommandSubcommandBuilder) => {
22 | return command
23 | .setName("weekly")
24 | .setDescription("Claim your weekly treasure!");
25 | };
26 |
27 | export const execute = async (interaction: ChatInputCommandInteraction) => {
28 | const { guild, user } = interaction;
29 |
30 | await deferReply(interaction, false);
31 | if (!guild) throw new GuildNotFoundError();
32 | if (!user) throw new UserNotFoundError();
33 |
34 | const guildCreditsSettings = await prisma.guildCreditsSettings.upsert({
35 | where: { id: guild.id },
36 | update: {},
37 | create: { id: guild.id },
38 | });
39 |
40 | const weeklyBonusAmount = guildCreditsSettings.weeklyBonusAmount;
41 | const userEconomy = await creditsManager.give(guild, user, weeklyBonusAmount);
42 |
43 | const embed = new EmbedBuilder()
44 | .setColor(process.env.EMBED_COLOR_SUCCESS)
45 | .setAuthor({
46 | name: "🌟 Weekly Treasure Claimed",
47 | })
48 | .setThumbnail(user.displayAvatarURL())
49 | .setDescription(
50 | `You've just claimed your weekly treasure of **${weeklyBonusAmount} credits**! 🎉\nEmbark on an epic adventure and spend your riches wisely.\n\n💰 **Your balance**: ${userEconomy.balance} credits`
51 | )
52 | .setFooter({
53 | text: `Claimed by ${user.username}`,
54 | iconURL: user.displayAvatarURL() || "",
55 | })
56 | .setTimestamp();
57 |
58 | await sendResponse(interaction, { embeds: [embed] });
59 |
60 | const cooldownDuration = 7 * 24 * 60 * 60; // 1 week in seconds
61 | const cooldownName = await generateCooldownName(interaction);
62 | await cooldownManager.setCooldown(
63 | await generateCooldownName(interaction),
64 | guild,
65 | user,
66 | startOfDay(addWeeks(new Date(), 1))
67 | );
68 | };
69 |
--------------------------------------------------------------------------------
/src/commands/credits/index.ts:
--------------------------------------------------------------------------------
1 | import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
2 | import {
3 | SubcommandGroupHandlers,
4 | SubcommandHandlers,
5 | executeSubcommand,
6 | } from "../../handlers/executeSubcommand";
7 | import * as balance from "./subcommands/balance";
8 | import * as gift from "./subcommands/gift";
9 | import * as top from "./subcommands/top";
10 | import * as work from "./subcommands/work";
11 |
12 | import * as bonus from "./groups/bonus";
13 |
14 | const subcommandHandlers: SubcommandHandlers = {
15 | balance: balance.execute,
16 | gift: gift.execute,
17 | top: top.execute,
18 | work: work.execute,
19 | };
20 |
21 | const subcommandGroupHandlers: SubcommandGroupHandlers = {
22 | bonus: {
23 | daily: bonus.subcommands.daily.execute,
24 | weekly: bonus.subcommands.weekly.execute,
25 | monthly: bonus.subcommands.monthly.execute,
26 | },
27 | };
28 |
29 | export const builder = new SlashCommandBuilder()
30 | .setName("credits")
31 | .setDescription("Manage your credits.")
32 | .setDMPermission(false)
33 | .addSubcommandGroup(bonus.builder)
34 | .addSubcommand(balance.builder)
35 | .addSubcommand(gift.builder)
36 | .addSubcommand(top.builder)
37 | .addSubcommand(work.builder);
38 |
39 | export const execute = async (interaction: ChatInputCommandInteraction) => {
40 | await executeSubcommand(
41 | interaction,
42 | subcommandHandlers,
43 | subcommandGroupHandlers
44 | );
45 | };
46 |
--------------------------------------------------------------------------------
/src/commands/credits/subcommands/balance/index.ts:
--------------------------------------------------------------------------------
1 | import { GuildMemberCredit } from "@prisma/client";
2 | import {
3 | ChatInputCommandInteraction,
4 | EmbedBuilder,
5 | SlashCommandSubcommandBuilder,
6 | User,
7 | } from "discord.js";
8 | import CreditsManager from "../../../../handlers/CreditsManager";
9 | import deferReply from "../../../../utils/deferReply";
10 | import { GuildNotFoundError } from "../../../../utils/errors";
11 | import sendResponse from "../../../../utils/sendResponse";
12 |
13 | const creditsManager = new CreditsManager();
14 |
15 | export const builder = (command: SlashCommandSubcommandBuilder) => {
16 | return command
17 | .setName("balance")
18 | .setDescription(`View account balance`)
19 | .addUserOption((option) =>
20 | option
21 | .setName("account")
22 | .setDescription(
23 | "Enter the username of another user to check their balance"
24 | )
25 | );
26 | };
27 |
28 | export const execute = async (interaction: ChatInputCommandInteraction) => {
29 | const { options, user, guild } = interaction;
30 |
31 | await deferReply(interaction, false);
32 | if (!guild) throw new GuildNotFoundError();
33 |
34 | const checkAccount = options.getUser("account") || user;
35 | const creditAccount = await creditsManager.balance(guild, checkAccount);
36 |
37 | const isUserCheckAccount = checkAccount.id === user.id;
38 | const pronoun = isUserCheckAccount ? "You" : "They";
39 | const possessivePronoun = isUserCheckAccount ? "Your" : "Their";
40 |
41 | const description = getAccountBalanceDescription(
42 | creditAccount,
43 | checkAccount,
44 | isUserCheckAccount,
45 | pronoun,
46 | possessivePronoun
47 | );
48 |
49 | await sendAccountBalanceEmbed(
50 | interaction,
51 | description,
52 | checkAccount,
53 | pronoun,
54 | possessivePronoun
55 | );
56 | };
57 |
58 | const getAccountBalanceDescription = (
59 | creditAccount: GuildMemberCredit,
60 | checkAccount: User,
61 | isUserCheckAccount: boolean,
62 | pronoun: string,
63 | possessivePronoun: string
64 | ) => {
65 | let description = `${
66 | isUserCheckAccount ? "You" : checkAccount
67 | } currently ${ isUserCheckAccount ? "have" : "has" } ${creditAccount.balance} credits. 💰\n\n`;
68 |
69 | if (creditAccount.balance === 0) {
70 | description += `${possessivePronoun} wallet is empty. Encourage ${
71 | isUserCheckAccount ? "yourself" : "them"
72 | } to start earning credits by participating in community events and challenges!`;
73 | } else if (creditAccount.balance < 100) {
74 | description += `${pronoun}'re making progress! Keep earning credits and unlock exciting rewards.`;
75 | } else if (creditAccount.balance < 500) {
76 | description += `Great job! ${possessivePronoun} account balance is growing. ${pronoun}'re on ${possessivePronoun.toLowerCase()} way to becoming a credit millionaire!`;
77 | } else {
78 | description += `Wow! ${pronoun}'re a credit master with a substantial account balance. Enjoy the perks and exclusive benefits!`;
79 | }
80 |
81 | return description;
82 | };
83 |
84 | const sendAccountBalanceEmbed = async (
85 | interaction: ChatInputCommandInteraction,
86 | description: string,
87 | checkAccount: User,
88 | pronoun: string,
89 | possessivePronoun: string
90 | ) => {
91 | await sendResponse(interaction, {
92 | embeds: [
93 | new EmbedBuilder()
94 | .setColor("#FDD835")
95 | .setAuthor({ name: "💳 Account Balance" })
96 | .setDescription(description)
97 | .setThumbnail(checkAccount.displayAvatarURL())
98 | .setFooter({
99 | text: `${possessivePronoun} credit balance reflects ${possessivePronoun.toLowerCase()} community engagement!`,
100 | })
101 | .setTimestamp(),
102 | ],
103 | });
104 | };
105 |
--------------------------------------------------------------------------------
/src/commands/credits/subcommands/gift/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ChatInputCommandInteraction,
3 | EmbedBuilder,
4 | Guild,
5 | SlashCommandSubcommandBuilder,
6 | User,
7 | codeBlock,
8 | } from "discord.js";
9 | import CreditsManager from "../../../../handlers/CreditsManager";
10 | import upsertGuildMember from "../../../../helpers/upsertGuildMember";
11 | import deferReply from "../../../../utils/deferReply";
12 | import { GuildNotFoundError } from "../../../../utils/errors";
13 | import sendResponse from "../../../../utils/sendResponse";
14 |
15 | const creditsManager = new CreditsManager();
16 |
17 | export const builder = (command: SlashCommandSubcommandBuilder) => {
18 | return command
19 | .setName("gift")
20 | .setDescription("🎁 Gift credits to an account")
21 | .addUserOption((option) =>
22 | option
23 | .setName("account")
24 | .setDescription("👤 The account you want to gift to")
25 | .setRequired(true)
26 | )
27 | .addIntegerOption((option) =>
28 | option
29 | .setName("amount")
30 | .setDescription("💰 The amount you want to gift")
31 | .setRequired(true)
32 | .setMinValue(1)
33 | .setMaxValue(2147483647)
34 | )
35 | .addStringOption((option) =>
36 | option
37 | .setName("message")
38 | .setDescription("💬 Your personalized message to the account")
39 | );
40 | };
41 |
42 | export const execute = async (interaction: ChatInputCommandInteraction) => {
43 | const { options, user, guild } = interaction;
44 |
45 | await deferReply(interaction, true);
46 | if (!guild) throw new GuildNotFoundError();
47 |
48 | const recipient = options.getUser("account", true);
49 | const amount = options.getInteger("amount");
50 | const message = options.getString("message");
51 |
52 | if (typeof amount !== "number" || amount < 1) {
53 | throw new Error("Please enter a valid number of credits to gift");
54 | }
55 |
56 | await upsertGuildMember(guild, user);
57 |
58 | await creditsManager.transfer(guild, user, recipient, amount);
59 |
60 | const recipientEmbed = await createRecipientEmbed(
61 | user,
62 | guild,
63 | recipient,
64 | amount,
65 | message
66 | );
67 | const senderEmbed = await createSenderEmbed(
68 | guild,
69 | user,
70 | recipient,
71 | amount,
72 | message
73 | );
74 |
75 | await recipient.send({ embeds: [recipientEmbed] });
76 |
77 | await sendResponse(interaction, { embeds: [senderEmbed] });
78 | };
79 |
80 | const createRecipientEmbed = async (
81 | sender: User,
82 | guild: Guild,
83 | recipient: User,
84 | amount: number,
85 | message: string | null
86 | ) => {
87 | const recipientEmbed = new EmbedBuilder()
88 | .setTimestamp()
89 | .setAuthor({
90 | name: `🎁 ${sender.username} sent you a gift!`,
91 | })
92 | .setColor(process.env.EMBED_COLOR_SUCCESS)
93 | .setDescription(`You've received ${amount} credits as a gift!`)
94 | .setThumbnail(sender.displayAvatarURL())
95 | .setFooter({
96 | text: `You received this gift in guild ${guild.name}`,
97 | iconURL: guild.iconURL() || "",
98 | });
99 |
100 | if (message) {
101 | recipientEmbed.addFields({
102 | name: "Message",
103 | value: codeBlock(message),
104 | });
105 | }
106 |
107 | return recipientEmbed;
108 | };
109 |
110 | const createSenderEmbed = async (
111 | guild: Guild,
112 | sender: User,
113 | recipient: User,
114 | amount: number,
115 | message: string | null
116 | ) => {
117 | const senderEmbed = new EmbedBuilder()
118 | .setTimestamp()
119 | .setAuthor({
120 | name: `🎁 You sent a gift to ${recipient.username}!`,
121 | })
122 | .setColor(process.env.EMBED_COLOR_SUCCESS)
123 | .setDescription(`You've sent ${amount} credits as a gift!`)
124 | .setThumbnail(recipient.displayAvatarURL())
125 | .setFooter({
126 | text: `The recipient received this gift in guild ${guild.name}`,
127 | iconURL: guild.iconURL() || "",
128 | });
129 |
130 | if (message) {
131 | senderEmbed.addFields({
132 | name: "Message",
133 | value: codeBlock(message),
134 | });
135 | }
136 |
137 | return senderEmbed;
138 | };
139 |
--------------------------------------------------------------------------------
/src/commands/credits/subcommands/top/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | CommandInteraction,
3 | EmbedBuilder,
4 | SlashCommandSubcommandBuilder,
5 | User,
6 | } from "discord.js";
7 | import CreditsManager from "../../../../handlers/CreditsManager";
8 | import deferReply from "../../../../utils/deferReply";
9 | import { GuildNotFoundError } from "../../../../utils/errors";
10 | import sendResponse from "../../../../utils/sendResponse";
11 |
12 | const creditsManager = new CreditsManager();
13 |
14 | export const builder = (command: SlashCommandSubcommandBuilder) => {
15 | return command
16 | .setName("top")
17 | .setDescription("View the top users in the server.");
18 | };
19 |
20 | export const execute = async (interaction: CommandInteraction) => {
21 | const { guild, client, user } = interaction;
22 |
23 | await deferReply(interaction, false);
24 | if (!guild) throw new GuildNotFoundError();
25 |
26 | const topTen = await creditsManager.topUsers(guild, 10);
27 |
28 | const embed = new EmbedBuilder()
29 | .setTimestamp()
30 | .setAuthor({ name: "🏅 Top Users" })
31 | .setColor(process.env.EMBED_COLOR_SUCCESS)
32 | .setFooter({
33 | text: `Requested by ${user.username}`,
34 | iconURL: user.displayAvatarURL(),
35 | });
36 |
37 | const medalEmojis = ["🥇", "🥈", "🥉"];
38 | const genericMedalEmoji = "🏅";
39 |
40 | const filteredTopTen = topTen.filter((topAccount) => topAccount.balance > 0);
41 |
42 | const fetchUser = async (userId: string): Promise => {
43 | const fetchedUser = await client.users.fetch(userId);
44 | return fetchedUser;
45 | };
46 |
47 | const topUsersDescription = await Promise.all(
48 | filteredTopTen.map(async (topAccount, index) => {
49 | const position = index + 1;
50 | const fetchedUser = await fetchUser(topAccount.userId);
51 | const userDisplayName = fetchedUser?.username || "Unknown User";
52 | const fieldContent = `${userDisplayName} with ${topAccount.balance} credits`;
53 | const medalEmoji =
54 | position <= 3 ? medalEmojis[position - 1] : genericMedalEmoji;
55 | return `\`${medalEmoji} ${fieldContent}\``;
56 | })
57 | );
58 |
59 | const description = `Here are the top users in this server:\n\n${topUsersDescription.join(
60 | "\n"
61 | )}`;
62 | embed.setDescription(description);
63 |
64 | await sendResponse(interaction, { embeds: [embed] });
65 | };
66 |
--------------------------------------------------------------------------------
/src/commands/credits/subcommands/work/index.ts:
--------------------------------------------------------------------------------
1 | import Chance from "chance";
2 | import { addHours } from "date-fns";
3 | import {
4 | ChatInputCommandInteraction,
5 | EmbedBuilder,
6 | SlashCommandSubcommandBuilder,
7 | } from "discord.js";
8 | import CooldownManager from "../../../../handlers/CooldownManager";
9 | import CreditsManager from "../../../../handlers/CreditsManager";
10 | import prisma from "../../../../handlers/prisma";
11 | import generateCooldownName from "../../../../helpers/generateCooldownName";
12 | import deferReply from "../../../../utils/deferReply";
13 | import { GuildNotFoundError } from "../../../../utils/errors";
14 | import sendResponse from "../../../../utils/sendResponse";
15 | import jobs from "./jobs";
16 |
17 | const cooldownManager = new CooldownManager();
18 | const creditsManager = new CreditsManager();
19 |
20 | export const builder = (command: SlashCommandSubcommandBuilder) => {
21 | return command
22 | .setName("work")
23 | .setDescription("Put in the hustle and earn some credits!");
24 | };
25 |
26 | const fallbackEmoji = "💼"; // Fallback work emoji
27 |
28 | export const execute = async (interaction: ChatInputCommandInteraction) => {
29 | const { guild, user } = interaction;
30 |
31 | await deferReply(interaction, false);
32 | if (!guild) throw new GuildNotFoundError();
33 |
34 | const chance = new Chance();
35 |
36 | const getRandomWork = () => {
37 | return chance.pickone(jobs);
38 | };
39 |
40 | // Retrieve settings from the GuildSettings model
41 | const guildCreditsSettings = await prisma.guildCreditsSettings.findUnique({
42 | where: { id: guild.id },
43 | });
44 |
45 | const baseCreditsRate = getRandomWork().creditsRate; // Get the base rate of credits earned per work action
46 | const bonusChance = guildCreditsSettings?.workBonusChance || 30; // Retrieve bonus chance from guild settings or use default value
47 | const penaltyChance = guildCreditsSettings?.workPenaltyChance || 10; // Retrieve penalty chance from guild settings or use default value
48 | const baseCreditsEarned = chance.integer({ min: 1, max: baseCreditsRate }); // Generate a random base number of credits earned
49 |
50 | let bonusCredits = 0; // Initialize bonus credits
51 | let penaltyCredits = 0; // Initialize penalty credits
52 | let creditsEarned = baseCreditsEarned; // Set the initial earned credits to the base amount
53 | let work;
54 |
55 | if (chance.bool({ likelihood: bonusChance })) {
56 | // Earn bonus credits
57 | const bonusMultiplier = chance.floating({ min: 1.1, max: 1.5 }); // Get a random multiplier for the bonus credits
58 | bonusCredits = Math.ceil(baseCreditsEarned * bonusMultiplier); // Calculate the bonus credits
59 | creditsEarned = baseCreditsEarned + bonusCredits; // Update the total earned credits
60 | work = getRandomWork(); // Get a random work type
61 | } else if (chance.bool({ likelihood: penaltyChance })) {
62 | // Receive a penalty
63 | const penaltyMultiplier = chance.floating({ min: 0.5, max: 0.8 }); // Get a random multiplier for the penalty credits
64 | penaltyCredits = Math.ceil(baseCreditsEarned * penaltyMultiplier); // Calculate the penalty credits
65 | creditsEarned = baseCreditsEarned - penaltyCredits; // Update the total earned credits
66 | work = getRandomWork(); // Get a random work type
67 | } else {
68 | // Earn base credits
69 | work = getRandomWork(); // Get a random work type
70 | }
71 |
72 | // Descriptions
73 | const descriptions = [];
74 |
75 | // Work Description
76 | descriptions.push(
77 | `Mission complete! You've earned **${baseCreditsEarned} credits**! 🎉`
78 | );
79 |
80 | // Bonus Description
81 | if (bonusCredits > 0) {
82 | descriptions.push(`💰 Bonus: **${bonusCredits} credits**!`);
83 | }
84 |
85 | // Penalty Description
86 | if (penaltyCredits !== 0) {
87 | descriptions.push(`😱 Penalty: **${penaltyCredits} credits** deducted.`);
88 | }
89 |
90 | // Total Credits Description
91 | descriptions.push(
92 | `Total earnings: **${creditsEarned} credits**. Keep up the hustle!`
93 | );
94 |
95 | if (creditsEarned > 0) {
96 | await creditsManager.give(guild, user, creditsEarned); // Give the user the earned credits
97 | }
98 |
99 | // User Balance
100 | const userBalance = await creditsManager.balance(guild, user);
101 |
102 | const embedSuccess = new EmbedBuilder()
103 | .setColor(process.env.EMBED_COLOR_SUCCESS)
104 | .setAuthor({
105 | name: `${user.username}'s Work Result`,
106 | iconURL: user.displayAvatarURL(),
107 | })
108 | .setTimestamp()
109 | .setDescription(descriptions.join("\n"))
110 | .setFooter({
111 | text: `${user.username} just worked as a ${work.name}! ${
112 | work?.emoji || fallbackEmoji
113 | }`,
114 | })
115 | .addFields({
116 | name: "New Balance",
117 | value: `${userBalance.balance} credits`,
118 | });
119 |
120 | await sendResponse(interaction, { embeds: [embedSuccess] });
121 |
122 | await cooldownManager.setCooldown(
123 | await generateCooldownName(interaction),
124 | guild,
125 | user,
126 | addHours(new Date(), 1)
127 | );
128 | };
129 |
--------------------------------------------------------------------------------
/src/commands/credits/subcommands/work/jobs.ts:
--------------------------------------------------------------------------------
1 | const jobs = [
2 | { name: "Software Developer", creditsRate: 37.5, emoji: "💻" },
3 | { name: "Graphic Designer", creditsRate: 30, emoji: "🎨" },
4 | { name: "Content Writer", creditsRate: 22.5, emoji: "📝" },
5 | { name: "Social Media Manager", creditsRate: 26.25, emoji: "📱" },
6 | { name: "Data Analyst", creditsRate: 33.75, emoji: "📊" },
7 | { name: "Video Editor", creditsRate: 28.125, emoji: "🎥" },
8 | { name: "Photographer", creditsRate: 26.25, emoji: "📸" },
9 | { name: "Marketing Specialist", creditsRate: 31.875, emoji: "📣" },
10 | { name: "Customer Support Representative", creditsRate: 18.75, emoji: "📞" },
11 | { name: "Translator", creditsRate: 24.375, emoji: "🌍" },
12 | { name: "Sales Representative", creditsRate: 30, emoji: "💼" },
13 | { name: "Event Planner", creditsRate: 28.125, emoji: "🎉" },
14 | { name: "Fitness Instructor", creditsRate: 22.5, emoji: "💪" },
15 | { name: "Chef", creditsRate: 33.75, emoji: "👨🍳" },
16 | { name: "Baker", creditsRate: 26.25, emoji: "🥐" },
17 | { name: "Barista", creditsRate: 20.625, emoji: "☕" },
18 | { name: "Personal Trainer", creditsRate: 30, emoji: "🏋️♂️" },
19 | { name: "Hair Stylist", creditsRate: 24.375, emoji: "💇" },
20 | { name: "Pet Groomer", creditsRate: 22.5, emoji: "🐶" },
21 | { name: "Gardener", creditsRate: 20.625, emoji: "🌿" },
22 | { name: "Game Developer", creditsRate: 35.625, emoji: "🎮" },
23 | { name: "Game Tester", creditsRate: 26.25, emoji: "🕹️" },
24 | { name: "Game Designer", creditsRate: 31.875, emoji: "🎲" },
25 | { name: "Linux System Administrator", creditsRate: 33.75, emoji: "🐧" },
26 | { name: "Network Engineer", creditsRate: 30, emoji: "🌐" },
27 | { name: "DevOps Engineer", creditsRate: 31.875, emoji: "🔧" },
28 | { name: "Database Administrator", creditsRate: 28.125, emoji: "🗄️" },
29 | { name: "Server Administrator", creditsRate: 30, emoji: "🖥️" },
30 | { name: "IT Security Specialist", creditsRate: 33.75, emoji: "🔒" },
31 | { name: "Cloud Architect", creditsRate: 31.875, emoji: "☁️" },
32 | { name: "Linux System Administrator", creditsRate: 33.75, emoji: "🐧" },
33 | { name: "Network Engineer", creditsRate: 30, emoji: "🌐" },
34 | { name: "DevOps Engineer", creditsRate: 31.875, emoji: "🔧" },
35 | { name: "Database Administrator", creditsRate: 28.125, emoji: "🗄️" },
36 | { name: "Server Administrator", creditsRate: 30, emoji: "🖥️" },
37 | { name: "IT Security Specialist", creditsRate: 33.75, emoji: "🔒" },
38 | { name: "Cloud Architect", creditsRate: 31.875, emoji: "☁️" },
39 | { name: "Linux Kernel Developer", creditsRate: 35.625, emoji: "🐧💻" },
40 | { name: "Linux Distribution Maintainer", creditsRate: 31.875, emoji: "📦🐧" },
41 | { name: "Linux System Engineer", creditsRate: 31.875, emoji: "🔩🐧" },
42 | { name: "Linux Network Administrator", creditsRate: 30, emoji: "🌐🐧" },
43 | { name: "Linux Security Analyst", creditsRate: 33.75, emoji: "🔒🐧" },
44 | {
45 | name: "Linux Virtualization Specialist",
46 | creditsRate: 31.875,
47 | emoji: "💻🐧",
48 | },
49 | { name: "Linux Storage Administrator", creditsRate: 30, emoji: "💾🐧" },
50 | { name: "Linux Web Server Administrator", creditsRate: 30, emoji: "🌐🐧" },
51 | { name: "Linux Automation Engineer", creditsRate: 31.875, emoji: "⚙️🐧" },
52 | {
53 | name: "Linux Desktop Support Specialist",
54 | creditsRate: 28.125,
55 | emoji: "🖥️🐧",
56 | },
57 | { name: "Linux Consultant", creditsRate: 33.75, emoji: "👩💼🐧" },
58 | { name: "Network Engineer", creditsRate: 30, emoji: "🌐" },
59 | { name: "Network Administrator", creditsRate: 28.125, emoji: "🔌🔧" },
60 | { name: "Network Security Engineer", creditsRate: 33.75, emoji: "🔒🔧" },
61 | { name: "Network Architect", creditsRate: 31.875, emoji: "🏛️🌐" },
62 | { name: "Wireless Network Engineer", creditsRate: 30, emoji: "📶🌐" },
63 | {
64 | name: "Network Operations Center (NOC) Technician",
65 | creditsRate: 26.25,
66 | emoji: "📡🔧",
67 | },
68 | { name: "Network Support Specialist", creditsRate: 26.25, emoji: "🔧📞" },
69 | { name: "Network Consultant", creditsRate: 33.75, emoji: "👩💼🌐" },
70 | { name: "Network Project Manager", creditsRate: 31.875, emoji: "👩💼📂🌐" },
71 | { name: "VoIP Engineer", creditsRate: 30, emoji: "☎️🌐" },
72 | ];
73 |
74 | export default jobs;
75 |
--------------------------------------------------------------------------------
/src/commands/dns/index.ts:
--------------------------------------------------------------------------------
1 | import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
2 | import {
3 | SubcommandHandlers,
4 | executeSubcommand,
5 | } from "../../handlers/executeSubcommand";
6 |
7 | import * as lookup from "./subcommands/lookup";
8 |
9 | const subcommandHandlers: SubcommandHandlers = {
10 | lookup: lookup.execute,
11 | };
12 |
13 | export const builder = new SlashCommandBuilder()
14 | .setName("dns")
15 | .setDescription("DNS commands.")
16 | .addSubcommand(lookup.builder);
17 |
18 | export const execute = async (interaction: ChatInputCommandInteraction) => {
19 | await executeSubcommand(interaction, subcommandHandlers);
20 | };
21 |
--------------------------------------------------------------------------------
/src/commands/dns/subcommands/lookup/index.ts:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import { addSeconds } from "date-fns";
3 | import {
4 | ChatInputCommandInteraction,
5 | EmbedBuilder,
6 | SlashCommandSubcommandBuilder,
7 | } from "discord.js";
8 | import dns from "node:dns";
9 | import { promisify } from "util";
10 | import { default as CooldownManager } from "../../../../handlers/CooldownManager";
11 | import generateCooldownName from "../../../../helpers/generateCooldownName";
12 | import deferReply from "../../../../utils/deferReply";
13 | import sendResponse from "../../../../utils/sendResponse";
14 |
15 | const cooldownManager = new CooldownManager();
16 |
17 | const dnsLookup = promisify(dns.lookup);
18 |
19 | export const builder = (
20 | command: SlashCommandSubcommandBuilder
21 | ): SlashCommandSubcommandBuilder => {
22 | return command
23 | .setName("lookup")
24 | .setDescription("Lookup a domain or IP.")
25 | .addStringOption((option) =>
26 | option
27 | .setName("query")
28 | .setDescription("The query you want to look up.")
29 | .setRequired(true)
30 | );
31 | };
32 |
33 | export const execute = async (
34 | interaction: ChatInputCommandInteraction
35 | ): Promise => {
36 | await deferReply(interaction, false);
37 |
38 | const { user, guild, options } = interaction;
39 | const query = options.getString("query", true);
40 |
41 | try {
42 | const { address } = await dnsLookup(query);
43 |
44 | const { data } = await axios.get(`https://ipinfo.io/${address}`);
45 |
46 | await sendResponse(interaction, {
47 | embeds: [
48 | new EmbedBuilder()
49 | .setAuthor({
50 | name: `Powered using IPinfo.io`,
51 | url: "https://ipinfo.io",
52 | iconURL: "https://ipinfo.io/static/favicon-96x96.png?v3",
53 | })
54 | .setColor(process.env.EMBED_COLOR_SUCCESS)
55 | .setFooter({
56 | text: `Requested by ${user.username}`,
57 | iconURL: user.displayAvatarURL(),
58 | })
59 | .setTimestamp().setDescription(`
60 | **IP**: ${data.ip}
61 | **Hostname**: ${data.hostname}
62 | **Organization**: ${data.org}
63 | **Anycast**: ${data.anycast ? "Yes" : "No"}
64 | **City**: ${data.city}
65 | **Region**: ${data.region}
66 | **Country**: ${data.country}
67 | **Location**: ${data.loc}
68 | **Postal**: ${data.postal}
69 | **Timezone**: ${data.timezone}
70 | `),
71 | ],
72 | });
73 |
74 | await cooldownManager.setCooldown(
75 | await generateCooldownName(interaction),
76 | guild || null,
77 | user,
78 | addSeconds(new Date(), 5)
79 | );
80 | } catch (error: unknown) {
81 | if ((error as NodeJS.ErrnoException).code === "ENOTFOUND") {
82 | throw new Error(
83 | `Sorry, we couldn't find the address for the requested query: ${query}.`
84 | );
85 | } else {
86 | throw error;
87 | }
88 | }
89 | };
90 |
--------------------------------------------------------------------------------
/src/commands/fun/index.ts:
--------------------------------------------------------------------------------
1 | import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
2 |
3 | // Subcommands
4 | import {
5 | SubcommandHandlers,
6 | executeSubcommand,
7 | } from "../../handlers/executeSubcommand";
8 | import * as meme from "./subcommands/meme";
9 |
10 | const subcommandHandlers: SubcommandHandlers = {
11 | meme: meme.execute,
12 | };
13 |
14 | export const builder = new SlashCommandBuilder()
15 | .setName("fun")
16 | .setDescription("Fun commands.")
17 |
18 | .addSubcommand(meme.builder);
19 |
20 | // Execute function
21 | export const execute = async (interaction: ChatInputCommandInteraction) => {
22 | await executeSubcommand(interaction, subcommandHandlers);
23 | };
24 |
--------------------------------------------------------------------------------
/src/commands/fun/subcommands/meme/index.ts:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import { addSeconds } from "date-fns";
3 | import {
4 | ActionRowBuilder,
5 | ButtonBuilder,
6 | ButtonStyle,
7 | ChatInputCommandInteraction,
8 | EmbedBuilder,
9 | SlashCommandSubcommandBuilder,
10 | TextChannel,
11 | } from "discord.js";
12 | import CooldownManager from "../../../../handlers/CooldownManager";
13 | import generateCooldownName from "../../../../helpers/generateCooldownName";
14 | import deferReply from "../../../../utils/deferReply";
15 | import sendResponse from "../../../../utils/sendResponse";
16 |
17 | const cooldownManager = new CooldownManager();
18 |
19 | interface MemeContent {
20 | title: string;
21 | url: string;
22 | author: string;
23 | ups: number;
24 | over_18: boolean;
25 | permalink: string;
26 | }
27 |
28 | interface AuthorData {
29 | icon_img: string;
30 | // Add other properties as needed
31 | }
32 |
33 | export const builder = (command: SlashCommandSubcommandBuilder) => {
34 | return command.setName("meme").setDescription("Random memes from r/memes");
35 | };
36 |
37 | export const execute = async (
38 | interaction: ChatInputCommandInteraction
39 | ): Promise => {
40 | await deferReply(interaction, false);
41 |
42 | const { channel, guild, user } = interaction;
43 |
44 | try {
45 | const content: MemeContent = await fetchRandomMeme();
46 | const authorData: AuthorData = await fetchAuthorData(content.author);
47 |
48 | if (channel instanceof TextChannel && channel.nsfw && content.over_18) {
49 | // NSFW handling logic (e.g., skip or show a warning message)
50 | return;
51 | }
52 |
53 | const buttons = createButtons(content.permalink);
54 | const embed = createEmbed(content, authorData);
55 |
56 | await sendResponse(interaction, {
57 | embeds: [embed],
58 | components: [buttons],
59 | });
60 | } catch (error) {
61 | throw new Error(
62 | "Sorry, we couldn't fetch a meme at the moment. Please try again later."
63 | );
64 | }
65 |
66 | await cooldownManager.setCooldown(
67 | await generateCooldownName(interaction),
68 | guild || null,
69 | user,
70 | addSeconds(new Date(), 5)
71 | );
72 | };
73 |
74 | async function fetchRandomMeme(): Promise {
75 | const { data } = await axios.get(
76 | "https://www.reddit.com/r/memes/random/.json"
77 | );
78 | const { children } = data[0].data;
79 | const content: MemeContent = children[0].data;
80 | return content;
81 | }
82 |
83 | async function fetchAuthorData(author: string): Promise {
84 | const { data } = await axios.get(
85 | `https://www.reddit.com/user/${author}/about.json`
86 | );
87 | const authorData: AuthorData = data.data;
88 | return authorData;
89 | }
90 |
91 | function createButtons(permalink: string) {
92 | return new ActionRowBuilder().addComponents(
93 | new ButtonBuilder()
94 | .setLabel("View post")
95 | .setStyle(ButtonStyle.Link)
96 | .setEmoji("🔗")
97 | .setURL(`https://reddit.com${permalink}`)
98 | );
99 | }
100 |
101 | function createEmbed(content: MemeContent, authorData: AuthorData) {
102 | return new EmbedBuilder()
103 | .setAuthor({ name: content.title })
104 | .setTimestamp()
105 | .setImage(content.url)
106 | .setFooter({
107 | text: `Meme by ${content.author} | 👍 ${content.ups}`,
108 | iconURL: authorData.icon_img.split("?").shift(),
109 | })
110 | .setColor(process.env.EMBED_COLOR_SUCCESS);
111 | }
112 |
--------------------------------------------------------------------------------
/src/commands/manage/groups/credits/index.ts:
--------------------------------------------------------------------------------
1 | import { SlashCommandSubcommandGroupBuilder } from "discord.js";
2 | import * as give from "./subcommands/give";
3 | import * as giveaway from "./subcommands/giveaway";
4 | import * as set from "./subcommands/set";
5 | import * as take from "./subcommands/take";
6 | import * as transfer from "./subcommands/transfer";
7 |
8 | export const builder = (group: SlashCommandSubcommandGroupBuilder) => {
9 | return group
10 | .setName("credits")
11 | .setDescription("Manage the credits of a user.")
12 | .addSubcommand(give.builder)
13 | .addSubcommand(set.builder)
14 | .addSubcommand(take.builder)
15 | .addSubcommand(transfer.builder)
16 | .addSubcommand(giveaway.builder);
17 | };
18 |
19 | export const subcommands = {
20 | give,
21 | set,
22 | take,
23 | transfer,
24 | giveaway,
25 | };
26 |
--------------------------------------------------------------------------------
/src/commands/manage/groups/credits/subcommands/give/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ChatInputCommandInteraction,
3 | EmbedBuilder,
4 | PermissionsBitField,
5 | SlashCommandSubcommandBuilder,
6 | } from "discord.js";
7 | import CreditsManager from "../../../../../../handlers/CreditsManager";
8 | import checkPermission from "../../../../../../utils/checkPermission";
9 | import deferReply from "../../../../../../utils/deferReply";
10 | import { GuildNotFoundError } from "../../../../../../utils/errors";
11 | import sendResponse from "../../../../../../utils/sendResponse";
12 |
13 | const creditsManager = new CreditsManager();
14 |
15 | export const builder = (
16 | command: SlashCommandSubcommandBuilder
17 | ): SlashCommandSubcommandBuilder => {
18 | return command
19 | .setName("give")
20 | .setDescription("Give credits to a user.")
21 | .addUserOption((option) =>
22 | option
23 | .setName("user")
24 | .setDescription("The user to give credits to.")
25 | .setRequired(true)
26 | )
27 | .addIntegerOption((option) =>
28 | option
29 | .setName("amount")
30 | .setDescription("The amount of credits to give.")
31 | .setRequired(true)
32 | .setMinValue(1)
33 | .setMaxValue(2147483647)
34 | );
35 | };
36 |
37 | export const execute = async (
38 | interaction: ChatInputCommandInteraction
39 | ): Promise => {
40 | const { guild, options, user } = interaction;
41 |
42 | await deferReply(interaction, false);
43 | checkPermission(interaction, PermissionsBitField.Flags.ManageGuild);
44 | if (!guild) throw new GuildNotFoundError();
45 |
46 | const discordReceiver = options.getUser("user", true);
47 | const creditsAmount = options.getInteger("amount", true);
48 |
49 | const embedSuccess = new EmbedBuilder()
50 | .setColor(process.env.EMBED_COLOR_SUCCESS)
51 | .setAuthor({ name: "💳 Credits Manager" })
52 | .setDescription(
53 | ` Successfully gave ${creditsAmount} credits to the user.`
54 | )
55 | .setFooter({
56 | text: `Action by ${user.username}`,
57 | iconURL: user.displayAvatarURL(),
58 | })
59 | .setTimestamp();
60 |
61 | await creditsManager.give(guild, discordReceiver, creditsAmount);
62 |
63 | await sendResponse(interaction, { embeds: [embedSuccess] });
64 | };
65 |
--------------------------------------------------------------------------------
/src/commands/manage/groups/credits/subcommands/giveaway/index.ts:
--------------------------------------------------------------------------------
1 | // Dependencies
2 | import {
3 | ActionRowBuilder,
4 | ButtonBuilder,
5 | ButtonStyle,
6 | ChannelType,
7 | ChatInputCommandInteraction,
8 | EmbedBuilder,
9 | PermissionsBitField,
10 | SlashCommandSubcommandBuilder,
11 | } from "discord.js";
12 | import { v4 as uuidv4 } from "uuid";
13 | // Configurations
14 | import CtrlPanelAPI from "../../../../../../services/CtrlPanelAPI";
15 | import checkPermission from "../../../../../../utils/checkPermission";
16 | import deferReply from "../../../../../../utils/deferReply";
17 | import { GuildNotFoundError } from "../../../../../../utils/errors";
18 | import sendResponse from "../../../../../../utils/sendResponse";
19 |
20 | // Function
21 | export const builder = (command: SlashCommandSubcommandBuilder) => {
22 | return command
23 | .setName("giveaway")
24 | .setDescription("Giveaway some credits for specified amount of users.")
25 | .addIntegerOption((option) =>
26 | option
27 | .setName("uses")
28 | .setDescription("How many users should be able to use this.")
29 | .setRequired(true)
30 | )
31 | .addIntegerOption((option) =>
32 | option
33 | .setName("credit")
34 | .setDescription(`How much credits provided per use.`)
35 | .setRequired(true)
36 | )
37 | .addChannelOption((option) =>
38 | option
39 | .setName("channel")
40 | .setDescription("The channel to send the message to.")
41 | .setRequired(true)
42 | .addChannelTypes(ChannelType.GuildText)
43 | );
44 | };
45 |
46 | export const execute = async (interaction: ChatInputCommandInteraction) => {
47 | const { guild, options } = interaction;
48 |
49 | await deferReply(interaction, true);
50 | checkPermission(interaction, PermissionsBitField.Flags.ManageGuild);
51 | if (!guild) throw new GuildNotFoundError();
52 |
53 | const ctrlPanelAPI = new CtrlPanelAPI(guild);
54 |
55 | const uses = options?.getInteger("uses", true);
56 | const creditAmount = options?.getInteger("credit", true);
57 | const channel = options?.getChannel("channel", true);
58 |
59 | const embedSuccess = new EmbedBuilder()
60 | .setTitle(":toolbox:︱Giveaway")
61 | .setColor("#FFFFFF")
62 | .setTimestamp(new Date());
63 |
64 | const code = uuidv4();
65 | const { redeemUrl } = await ctrlPanelAPI.generateVoucher(
66 | code,
67 | creditAmount,
68 | uses
69 | );
70 |
71 | await sendResponse(interaction, {
72 | embeds: [embedSuccess.setDescription(`Successfully created code: ${code}`)],
73 | });
74 |
75 | const buttons = new ActionRowBuilder().addComponents(
76 | new ButtonBuilder()
77 | .setLabel("Redeem it here")
78 | .setStyle(ButtonStyle.Link)
79 | .setEmoji("🏦")
80 | .setURL(`${redeemUrl}`)
81 | );
82 |
83 | const discordChannel = await guild.channels.fetch(channel.id);
84 | if (!discordChannel) return;
85 | if (discordChannel.type !== ChannelType.GuildText) return;
86 |
87 | discordChannel.send({
88 | embeds: [
89 | embedSuccess
90 | .addFields([
91 | {
92 | name: "💶 Credits",
93 | value: `${creditAmount}`,
94 | inline: true,
95 | },
96 | ])
97 | .setDescription(
98 | `${interaction.user} dropped a voucher for a maximum **${uses}** members!`
99 | ),
100 | ],
101 | components: [buttons],
102 | });
103 | };
104 |
--------------------------------------------------------------------------------
/src/commands/manage/groups/credits/subcommands/set/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ChatInputCommandInteraction,
3 | EmbedBuilder,
4 | PermissionsBitField,
5 | SlashCommandSubcommandBuilder,
6 | } from "discord.js";
7 | import CreditsManager from "../../../../../../handlers/CreditsManager";
8 | import checkPermission from "../../../../../../utils/checkPermission";
9 | import deferReply from "../../../../../../utils/deferReply";
10 | import { GuildNotFoundError } from "../../../../../../utils/errors";
11 | import sendResponse from "../../../../../../utils/sendResponse";
12 |
13 | const creditsManager = new CreditsManager();
14 |
15 | export const builder = (command: SlashCommandSubcommandBuilder) => {
16 | return command
17 | .setName("set")
18 | .setDescription("Set credits to a user.")
19 | .addUserOption((option) =>
20 | option
21 | .setName("user")
22 | .setDescription("The user to set credits to.")
23 | .setRequired(true)
24 | )
25 | .addIntegerOption((option) =>
26 | option
27 | .setName("amount")
28 | .setDescription(`The amount of credits to set.`)
29 | .setRequired(true)
30 | .setMinValue(1)
31 | .setMaxValue(2147483647)
32 | );
33 | };
34 |
35 | export const execute = async (
36 | interaction: ChatInputCommandInteraction
37 | ): Promise => {
38 | const { guild, options, user } = interaction;
39 |
40 | await deferReply(interaction, false);
41 | checkPermission(interaction, PermissionsBitField.Flags.ManageGuild);
42 | if (!guild) throw new GuildNotFoundError();
43 |
44 | const discordReceiver = options.getUser("user", true);
45 | const creditsAmount = options.getInteger("amount", true);
46 |
47 | const embedSuccess = new EmbedBuilder()
48 | .setColor(process.env.EMBED_COLOR_SUCCESS)
49 | .setAuthor({ name: "💳 Credits Manager" })
50 | .setDescription(
51 | ` Successfully set ${creditsAmount} credits to the user.`
52 | )
53 | .setFooter({
54 | text: `Action by ${user.username}`,
55 | iconURL: user.displayAvatarURL(),
56 | })
57 | .setTimestamp();
58 |
59 | await creditsManager.set(guild, discordReceiver, creditsAmount);
60 |
61 | await sendResponse(interaction, { embeds: [embedSuccess] });
62 | };
63 |
--------------------------------------------------------------------------------
/src/commands/manage/groups/credits/subcommands/take/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ChatInputCommandInteraction,
3 | EmbedBuilder,
4 | PermissionsBitField,
5 | SlashCommandSubcommandBuilder,
6 | } from "discord.js";
7 | import CreditsManager from "../../../../../../handlers/CreditsManager";
8 | import checkPermission from "../../../../../../utils/checkPermission";
9 | import deferReply from "../../../../../../utils/deferReply";
10 | import { GuildNotFoundError } from "../../../../../../utils/errors";
11 | import sendResponse from "../../../../../../utils/sendResponse";
12 |
13 | const creditsManager = new CreditsManager();
14 |
15 | export const builder = (command: SlashCommandSubcommandBuilder) => {
16 | return command
17 | .setName("take")
18 | .setDescription("Take credits from a user.")
19 | .addUserOption((option) =>
20 | option
21 | .setName("user")
22 | .setDescription("The user to take credits from.")
23 | .setRequired(true)
24 | )
25 | .addIntegerOption((option) =>
26 | option
27 | .setName("amount")
28 | .setDescription(`The amount of credits to take.`)
29 | .setRequired(true)
30 | .setMinValue(1)
31 | .setMaxValue(2147483647)
32 | );
33 | };
34 |
35 | export const execute = async (
36 | interaction: ChatInputCommandInteraction
37 | ): Promise => {
38 | const { guild, options, user } = interaction;
39 |
40 | await deferReply(interaction, false);
41 | checkPermission(interaction, PermissionsBitField.Flags.ManageGuild);
42 | if (!guild) throw new GuildNotFoundError();
43 |
44 | const discordReceiver = options.getUser("user", true);
45 | const creditsAmount = options.getInteger("amount", true);
46 |
47 | const embedSuccess = new EmbedBuilder()
48 | .setColor(process.env.EMBED_COLOR_SUCCESS)
49 | .setAuthor({ name: "💳 Credits Manager" })
50 | .setDescription(
51 | ` Successfully took ${creditsAmount} credits to the user.`
52 | )
53 | .setFooter({
54 | text: `Action by ${user.username}`,
55 | iconURL: user.displayAvatarURL(),
56 | })
57 | .setTimestamp();
58 |
59 | await creditsManager.take(guild, discordReceiver, creditsAmount);
60 |
61 | await sendResponse(interaction, { embeds: [embedSuccess] });
62 | };
63 |
--------------------------------------------------------------------------------
/src/commands/manage/groups/credits/subcommands/transfer/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ChatInputCommandInteraction,
3 | EmbedBuilder,
4 | PermissionsBitField,
5 | SlashCommandSubcommandBuilder,
6 | } from "discord.js";
7 | import CreditsManager from "../../../../../../handlers/CreditsManager";
8 | import checkPermission from "../../../../../../utils/checkPermission";
9 | import deferReply from "../../../../../../utils/deferReply";
10 | import { GuildNotFoundError } from "../../../../../../utils/errors";
11 | import sendResponse from "../../../../../../utils/sendResponse";
12 |
13 | const creditsManager = new CreditsManager();
14 |
15 | export const builder = (command: SlashCommandSubcommandBuilder) => {
16 | return command
17 | .setName("transfer")
18 | .setDescription("Transfer credits from a user to another.")
19 | .addUserOption((option) =>
20 | option
21 | .setName("from-user")
22 | .setDescription("The user to take credits from.")
23 | .setRequired(true)
24 | )
25 | .addUserOption((option) =>
26 | option
27 | .setName("to-user")
28 | .setDescription("The user to give credits to.")
29 | .setRequired(true)
30 | )
31 | .addIntegerOption((option) =>
32 | option
33 | .setName("amount")
34 | .setDescription(`The amount of credits to set.`)
35 | .setRequired(true)
36 | .setMinValue(1)
37 | .setMaxValue(2147483647)
38 | );
39 | };
40 |
41 | export const execute = async (
42 | interaction: ChatInputCommandInteraction
43 | ): Promise => {
44 | const { guild, options, user } = interaction;
45 |
46 | await deferReply(interaction, false);
47 | checkPermission(interaction, PermissionsBitField.Flags.ManageGuild);
48 | if (!guild) throw new GuildNotFoundError();
49 |
50 | const fromUser = options.getUser("from-user", true);
51 | const toUser = options.getUser("to-user", true);
52 | const creditsAmount = options.getInteger("amount", true);
53 |
54 | const transactionResult = await creditsManager.transfer(
55 | guild,
56 | fromUser,
57 | toUser,
58 | creditsAmount
59 | );
60 |
61 | // Constructing the transfer embed
62 | const transferEmbed = new EmbedBuilder()
63 | .setColor(process.env.EMBED_COLOR_SUCCESS)
64 | .addFields(
65 | { name: "📤 Sender", value: fromUser.username, inline: true },
66 | { name: "📥 Recipient", value: toUser.username, inline: true },
67 | {
68 | name: "💰 Transferred Amount",
69 | value: `${transactionResult.transferredAmount}`,
70 | inline: true,
71 | },
72 | {
73 | name: "🪙 Sender Balance",
74 | value: `${transactionResult.fromTransaction.balance}`,
75 | inline: true,
76 | },
77 | {
78 | name: "🪙 Recipient Balance",
79 | value: `${transactionResult.toTransaction.balance}`,
80 | inline: true,
81 | }
82 | )
83 | .setAuthor({ name: "This is an administrative action." })
84 | //.setThumbnail(user.displayAvatarURL())
85 | .setFooter({
86 | text: `Action by ${user.username}`,
87 | iconURL: user.displayAvatarURL(),
88 | })
89 | .setTimestamp();
90 |
91 | // Adding explanation if not all credits were transferred
92 | if (creditsAmount !== transactionResult.transferredAmount) {
93 | transferEmbed.setAuthor({
94 | name: `⚠️ Some credits could not be transferred.`,
95 | });
96 | const explanation = `*This is because the transfer amount exceeded the maximum allowed limit.*`;
97 | transferEmbed.setDescription(explanation);
98 | } else {
99 | transferEmbed.setAuthor({
100 | name: "✅ All credits have been successfully transferred.",
101 | });
102 | }
103 |
104 | await sendResponse(interaction, { embeds: [transferEmbed] });
105 | };
106 |
--------------------------------------------------------------------------------
/src/commands/manage/index.ts:
--------------------------------------------------------------------------------
1 | import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
2 | import {
3 | SubcommandGroupHandlers,
4 | executeSubcommand,
5 | } from "../../handlers/executeSubcommand";
6 | import * as credits from "./groups/credits";
7 |
8 | const subcommandGroupHandlers: SubcommandGroupHandlers = {
9 | credits: {
10 | give: credits.subcommands.give.execute,
11 | giveaway: credits.subcommands.giveaway.execute,
12 | set: credits.subcommands.set.execute,
13 | take: credits.subcommands.take.execute,
14 | transfer: credits.subcommands.transfer.execute,
15 | },
16 | };
17 |
18 | export const builder = new SlashCommandBuilder()
19 | .setName("manage")
20 | .setDescription("Manage the bot.")
21 | .setDMPermission(false)
22 | .addSubcommandGroup(credits.builder);
23 |
24 | export const execute = async (interaction: ChatInputCommandInteraction) => {
25 | await executeSubcommand(interaction, {}, subcommandGroupHandlers);
26 | };
27 |
--------------------------------------------------------------------------------
/src/commands/moderation/index.ts:
--------------------------------------------------------------------------------
1 | import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
2 | import {
3 | SubcommandHandlers,
4 | executeSubcommand,
5 | } from "../../handlers/executeSubcommand";
6 |
7 | import * as prune from "./subcommands/prune";
8 |
9 | const subcommandHandlers: SubcommandHandlers = {
10 | prune: prune.execute,
11 | };
12 |
13 | export const builder = new SlashCommandBuilder()
14 | .setName("moderation")
15 | .setDescription("Moderation.")
16 | .setDMPermission(false)
17 | .addSubcommand(prune.builder);
18 |
19 | export const execute = async (interaction: ChatInputCommandInteraction) => {
20 | await executeSubcommand(interaction, subcommandHandlers);
21 | };
22 |
--------------------------------------------------------------------------------
/src/commands/moderation/subcommands/prune/index.ts:
--------------------------------------------------------------------------------
1 | // Dependencies
2 | import {
3 | ChannelType,
4 | ChatInputCommandInteraction,
5 | EmbedBuilder,
6 | PermissionsBitField,
7 | SlashCommandSubcommandBuilder,
8 | } from "discord.js";
9 | import checkPermission from "../../../../utils/checkPermission";
10 | import deferReply from "../../../../utils/deferReply";
11 | import {
12 | ChannelNotFoundError,
13 | GuildNotFoundError,
14 | } from "../../../../utils/errors";
15 | import sendResponse from "../../../../utils/sendResponse";
16 |
17 | // Function
18 | export const builder = (command: SlashCommandSubcommandBuilder) => {
19 | return command
20 | .setName("prune")
21 | .setDescription("Prune messages!")
22 | .addIntegerOption((option) =>
23 | option
24 | .setName("count")
25 | .setDescription("How many messages you wish to prune")
26 | .setRequired(true)
27 | .setMinValue(1)
28 | .setMaxValue(99)
29 | )
30 | .addBooleanOption((option) =>
31 | option
32 | .setName("bots")
33 | .setDescription("Should bot messages be pruned too?")
34 | );
35 | };
36 |
37 | export const execute = async (interaction: ChatInputCommandInteraction) => {
38 | const { guild, user, options, channel } = interaction;
39 |
40 | await deferReply(interaction, false);
41 | checkPermission(interaction, PermissionsBitField.Flags.ManageMessages);
42 | if (!guild) throw new GuildNotFoundError();
43 | if (!channel) throw new ChannelNotFoundError();
44 |
45 | const count = options.getInteger("count", true);
46 | const bots = options.getBoolean("bots");
47 | if (count < 1 || count > 99) {
48 | throw new Error(
49 | "Please provide a number between 1 and 99 for the prune command."
50 | );
51 | }
52 |
53 | if (channel.type !== ChannelType.GuildText)
54 | throw new Error("This channel is not a text channel in a guild!");
55 |
56 | const messagesToDelete = await channel.messages
57 | .fetch({ limit: count + 1 }) // Fetch count + 1 messages to exclude the interaction message itself
58 | .then((messages) => {
59 | let filteredMessages = messages;
60 | if (!bots) {
61 | filteredMessages = filteredMessages.filter(
62 | (message) => !message.author.bot
63 | );
64 | }
65 | return filteredMessages;
66 | });
67 |
68 | const firstMessage = messagesToDelete.first();
69 | if (firstMessage) messagesToDelete.delete(firstMessage.id);
70 |
71 | const messagesToDeleteArray = [...messagesToDelete.values()]; // Convert Collection to an array
72 |
73 | await channel.bulkDelete(messagesToDeleteArray, true).then(async () => {
74 | const interactionEmbed = new EmbedBuilder()
75 | .setColor(process.env.EMBED_COLOR_SUCCESS)
76 | .setAuthor({ name: "🤖 Moderation" })
77 | .setDescription(
78 | `Successfully deleted ${messagesToDeleteArray.length} messages.`
79 | )
80 | .setFooter({
81 | text: `Action by ${user.username}`,
82 | iconURL: user.displayAvatarURL(),
83 | })
84 | .setTimestamp();
85 |
86 | await sendResponse(interaction, {
87 | embeds: [interactionEmbed],
88 | });
89 | });
90 | };
91 |
--------------------------------------------------------------------------------
/src/commands/quotes/index.ts:
--------------------------------------------------------------------------------
1 | import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
2 |
3 | // Subcommands
4 | import {
5 | SubcommandHandlers,
6 | executeSubcommand,
7 | } from "../../handlers/executeSubcommand";
8 | import * as post from "./subcommands/post";
9 |
10 | const subcommandHandlers: SubcommandHandlers = {
11 | post: post.execute,
12 | };
13 |
14 | export const builder = new SlashCommandBuilder()
15 | .setName("quotes")
16 | .setDescription("Fun commands.")
17 |
18 | .addSubcommand(post.builder);
19 |
20 | // Execute function
21 | export const execute = async (interaction: ChatInputCommandInteraction) => {
22 | await executeSubcommand(interaction, subcommandHandlers);
23 | };
24 |
--------------------------------------------------------------------------------
/src/commands/quotes/subcommands/post/index.ts:
--------------------------------------------------------------------------------
1 | import { addMinutes } from "date-fns";
2 | import {
3 | ChannelType,
4 | ChatInputCommandInteraction,
5 | EmbedBuilder,
6 | SlashCommandSubcommandBuilder,
7 | } from "discord.js";
8 | import CooldownManager from "../../../../handlers/CooldownManager";
9 | import prisma from "../../../../handlers/prisma";
10 | import generateCooldownName from "../../../../helpers/generateCooldownName";
11 | import upsertGuildMember from "../../../../helpers/upsertGuildMember";
12 | import deferReply from "../../../../utils/deferReply";
13 | import {
14 | ChannelNotFoundError,
15 | GuildNotFoundError,
16 | } from "../../../../utils/errors";
17 | import sendResponse from "../../../../utils/sendResponse";
18 |
19 | const cooldownManager = new CooldownManager();
20 |
21 | export const builder = (command: SlashCommandSubcommandBuilder) => {
22 | return command
23 | .setName("post")
24 | .setDescription("Post a quote someone said in this server")
25 | .addUserOption((option) =>
26 | option
27 | .setName("user")
28 | .setDescription("The user who said this")
29 | .setRequired(true)
30 | )
31 | .addStringOption((option) =>
32 | option
33 | .setName("message")
34 | .setDescription("What the user said")
35 | .setRequired(true)
36 | );
37 | };
38 |
39 | export const execute = async (
40 | interaction: ChatInputCommandInteraction
41 | ): Promise => {
42 | const { options, guild, user } = interaction;
43 |
44 | await deferReply(interaction, true);
45 | if (!guild) throw new GuildNotFoundError();
46 |
47 | const quoteUser = options.getUser("user", true);
48 | const quoteString = options.getString("message", true);
49 |
50 | if (quoteUser.id == user.id) throw new Error("One cannot quote oneself.");
51 |
52 | await upsertGuildMember(guild, user);
53 | await upsertGuildMember(guild, quoteUser);
54 |
55 | const guildQuotesSettings = await prisma.guildQuotesSettings.findUnique({
56 | where: { id: guild.id },
57 | });
58 |
59 | if (!guildQuotesSettings) throw new Error("No configuration available.");
60 |
61 | if (guildQuotesSettings.status !== true)
62 | throw new Error("Quotes are disabled in this server.");
63 |
64 | const channel = await interaction.client.channels.fetch(
65 | guildQuotesSettings.quoteChannelId
66 | );
67 |
68 | if (!channel) throw new ChannelNotFoundError();
69 |
70 | if (channel.type !== ChannelType.GuildText)
71 | throw new Error("The channel is not a text channel.");
72 |
73 | await prisma.quotes.create({
74 | data: {
75 | guildId: guild.id,
76 | userId: quoteUser.id,
77 | posterUserId: user.id,
78 | message: quoteString,
79 | },
80 | });
81 |
82 | const quoteEmbed = new EmbedBuilder()
83 | .setAuthor({
84 | name: `Quote of ${quoteUser.username}`,
85 | iconURL: quoteUser.displayAvatarURL(),
86 | })
87 | .setColor(process.env.EMBED_COLOR_SUCCESS)
88 | .setDescription(quoteString)
89 | .setFooter({
90 | text: `Posted by ${user.username}`,
91 | iconURL: user.displayAvatarURL(),
92 | });
93 |
94 | const sentMessage = await channel.send({ embeds: [quoteEmbed] });
95 |
96 | await sentMessage.react("👍");
97 | await sentMessage.react("👎");
98 |
99 | const postEmbed = new EmbedBuilder()
100 | .setColor(process.env.EMBED_COLOR_SUCCESS)
101 | .setDescription("Successfully posted the quote!");
102 |
103 | await sendResponse(interaction, { embeds: [postEmbed] });
104 |
105 | await cooldownManager.setCooldown(
106 | await generateCooldownName(interaction),
107 | guild,
108 | user,
109 | addMinutes(new Date(), 5)
110 | );
111 | };
112 |
--------------------------------------------------------------------------------
/src/commands/reputation/index.ts:
--------------------------------------------------------------------------------
1 | import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
2 | import {
3 | SubcommandHandlers,
4 | executeSubcommand,
5 | } from "../../handlers/executeSubcommand";
6 |
7 | import * as check from "./subcommands/check";
8 | import * as repute from "./subcommands/repute";
9 |
10 | const subcommandHandlers: SubcommandHandlers = {
11 | repute: repute.execute,
12 | check: check.execute,
13 | };
14 |
15 | export const builder = new SlashCommandBuilder()
16 | .setName("reputation")
17 | .setDescription(
18 | "See and give reputation to users to show others how trustworthy they are"
19 | )
20 | .setDMPermission(false)
21 | .addSubcommand(repute.builder)
22 | .addSubcommand(check.builder);
23 |
24 | export const execute = async (interaction: ChatInputCommandInteraction) => {
25 | await executeSubcommand(interaction, subcommandHandlers);
26 | };
27 |
--------------------------------------------------------------------------------
/src/commands/reputation/subcommands/check/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ChatInputCommandInteraction,
3 | EmbedBuilder,
4 | SlashCommandSubcommandBuilder,
5 | } from "discord.js";
6 | import ReputationManager from "../../../../handlers/ReputationManager";
7 | import deferReply from "../../../../utils/deferReply";
8 | import sendResponse from "../../../../utils/sendResponse";
9 |
10 | const reputationManager = new ReputationManager();
11 |
12 | export const builder = (command: SlashCommandSubcommandBuilder) => {
13 | return command
14 | .setName("check")
15 | .setDescription("Check reputation")
16 | .addUserOption((option) =>
17 | option
18 | .setName("user")
19 | .setDescription("The user you are checking")
20 | .setRequired(false)
21 | );
22 | };
23 |
24 | export const execute = async (interaction: ChatInputCommandInteraction) => {
25 | const { options, user } = interaction;
26 |
27 | await deferReply(interaction, false);
28 | const checkUser = options.getUser("user") || user;
29 |
30 | const userReputation = await reputationManager.check(checkUser);
31 |
32 | const interactionEmbed = new EmbedBuilder()
33 | .setAuthor({
34 | name: `Showing ${checkUser.username}'s reputation`,
35 | })
36 | .setDescription(
37 | `**User:** ${checkUser}\n\n` +
38 | `**Reputation:**\n` +
39 | `- Negative: ${userReputation.negative}\n` +
40 | `- Positive: ${userReputation.positive}\n` +
41 | `- Total: ${userReputation.total}`
42 | )
43 | .setFooter({
44 | text: `Requested by ${user.username}`,
45 | iconURL: user.displayAvatarURL(),
46 | })
47 | .setThumbnail(checkUser.displayAvatarURL())
48 | .setTimestamp()
49 | .setColor(process.env.EMBED_COLOR_SUCCESS);
50 |
51 | await sendResponse(interaction, {
52 | embeds: [interactionEmbed],
53 | });
54 | };
55 |
--------------------------------------------------------------------------------
/src/commands/reputation/subcommands/repute/index.ts:
--------------------------------------------------------------------------------
1 | import { addDays } from "date-fns";
2 | import {
3 | ChatInputCommandInteraction,
4 | EmbedBuilder,
5 | SlashCommandSubcommandBuilder,
6 | } from "discord.js";
7 | import CooldownManager from "../../../../handlers/CooldownManager";
8 | import ReputationManager from "../../../../handlers/ReputationManager";
9 | import generateCooldownName from "../../../../helpers/generateCooldownName";
10 | import deferReply from "../../../../utils/deferReply";
11 | import { GuildNotFoundError } from "../../../../utils/errors";
12 | import sendResponse from "../../../../utils/sendResponse";
13 |
14 | const cooldownManager = new CooldownManager();
15 | const reputationManager = new ReputationManager();
16 |
17 | export const builder = (command: SlashCommandSubcommandBuilder) => {
18 | return command
19 | .setName("repute")
20 | .setDescription("Repute a user")
21 | .addUserOption((option) =>
22 | option
23 | .setName("user")
24 | .setDescription("The user you repute")
25 | .setRequired(true)
26 | )
27 | .addStringOption((option) =>
28 | option
29 | .setName("type")
30 | .setDescription("Type of reputation")
31 | .setRequired(true)
32 | .addChoices(
33 | { name: "Positive", value: "positive" },
34 | { name: "Negative", value: "negative" }
35 | )
36 | );
37 | };
38 |
39 | export const execute = async (interaction: ChatInputCommandInteraction) => {
40 | const { options, user, guild } = interaction;
41 |
42 | await deferReply(interaction, true);
43 | if (!guild) throw new GuildNotFoundError();
44 |
45 | const targetUser = options.getUser("user", true);
46 | const reputationType = options.getString("type", true);
47 |
48 | if (reputationType !== "positive" && reputationType !== "negative") {
49 | throw new Error("Invalid reputation type");
50 | }
51 |
52 | if (user.id === targetUser.id) {
53 | throw new Error("It is not possible to give yourself reputation.");
54 | }
55 |
56 | await reputationManager.repute(targetUser, reputationType);
57 |
58 | const emoji = reputationType === "positive" ? "😊" : "😔";
59 |
60 | const interactionMessage = `You have successfully given ${emoji} ${reputationType} reputation to ${targetUser}!`;
61 |
62 | const interactionEmbed = new EmbedBuilder()
63 | .setAuthor({
64 | name: `Reputing ${targetUser.username}`,
65 | iconURL: targetUser.displayAvatarURL(),
66 | })
67 | .setDescription(interactionMessage)
68 | .setTimestamp()
69 | .setColor(process.env.EMBED_COLOR_SUCCESS);
70 |
71 | await sendResponse(interaction, {
72 | embeds: [interactionEmbed],
73 | });
74 |
75 | await cooldownManager.setCooldown(
76 | await generateCooldownName(interaction),
77 | guild,
78 | user,
79 | addDays(new Date(), 1)
80 | );
81 | };
82 |
--------------------------------------------------------------------------------
/src/commands/settings/index.ts:
--------------------------------------------------------------------------------
1 | import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
2 |
3 | import {
4 | SubcommandHandlers,
5 | executeSubcommand,
6 | } from "../../handlers/executeSubcommand";
7 | import * as credits from "./subcommands/credits";
8 | import * as ctrlpanel from "./subcommands/ctrlpanel";
9 | import * as quotes from "./subcommands/quotes";
10 |
11 | const subcommandHandlers: SubcommandHandlers = {
12 | ctrlpanel: ctrlpanel.execute,
13 | credits: credits.execute,
14 | quotes: quotes.execute,
15 | };
16 |
17 | export const builder = new SlashCommandBuilder()
18 | .setName("settings")
19 | .setDescription("Manage guild configurations.")
20 | .setDMPermission(false)
21 | .addSubcommand(ctrlpanel.builder)
22 | .addSubcommand(credits.builder)
23 | .addSubcommand(quotes.builder);
24 |
25 | export const execute = async (interaction: ChatInputCommandInteraction) => {
26 | await executeSubcommand(interaction, subcommandHandlers);
27 | };
28 |
--------------------------------------------------------------------------------
/src/commands/settings/subcommands/credits/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ChatInputCommandInteraction,
3 | EmbedBuilder,
4 | PermissionsBitField,
5 | SlashCommandSubcommandBuilder,
6 | } from "discord.js";
7 | import prisma from "../../../../handlers/prisma";
8 | import checkPermission from "../../../../utils/checkPermission";
9 | import deferReply from "../../../../utils/deferReply";
10 | import { GuildNotFoundError } from "../../../../utils/errors";
11 | import sendResponse from "../../../../utils/sendResponse";
12 |
13 | export const builder = (command: SlashCommandSubcommandBuilder) => {
14 | return command
15 | .setName("credits")
16 | .setDescription(`Configure credits module`)
17 | .addNumberOption((option) =>
18 | option
19 | .setName("work-bonus-chance")
20 | .setDescription("work-bonus-chance")
21 | .setRequired(true)
22 | .setMinValue(0)
23 | .setMaxValue(100)
24 | )
25 | .addNumberOption((option) =>
26 | option
27 | .setName("work-penalty-chance")
28 | .setDescription("work-penalty-chance")
29 | .setRequired(true)
30 | .setMinValue(0)
31 | .setMaxValue(100)
32 | );
33 | };
34 |
35 | export const execute = async (interaction: ChatInputCommandInteraction) => {
36 | const { guild, options, user } = interaction;
37 |
38 | await deferReply(interaction, true);
39 | checkPermission(interaction, PermissionsBitField.Flags.ManageGuild);
40 | if (!guild) throw new GuildNotFoundError();
41 |
42 | const workBonusChance = options.getNumber("work-bonus-chance", true);
43 | const workPenaltyChance = options.getNumber("work-penalty-chance", true);
44 |
45 | const upsertGuildCreditsSettings = await prisma.guildCreditsSettings.upsert({
46 | where: {
47 | id: guild.id,
48 | },
49 | update: {
50 | workBonusChance,
51 | workPenaltyChance,
52 | },
53 | create: {
54 | id: guild.id,
55 | workBonusChance,
56 | workPenaltyChance,
57 | guildSettings: {
58 | connectOrCreate: {
59 | where: {
60 | id: guild.id,
61 | },
62 | create: {
63 | id: guild.id,
64 | },
65 | },
66 | },
67 | },
68 | });
69 |
70 | const embedSuccess = new EmbedBuilder()
71 | .setAuthor({
72 | name: "Configuration of Credits",
73 | })
74 | .setColor(process.env.EMBED_COLOR_SUCCESS)
75 | .setFooter({
76 | text: `Successfully configured by ${user.username}`,
77 | iconURL: user.displayAvatarURL(),
78 | })
79 | .setTimestamp();
80 |
81 | await sendResponse(interaction, {
82 | embeds: [
83 | embedSuccess
84 | .setDescription("Configuration updated successfully!")
85 | .addFields(
86 | {
87 | name: "Work Bonus Chance",
88 | value: `${upsertGuildCreditsSettings.workBonusChance}`,
89 | inline: true,
90 | },
91 | {
92 | name: "Work Penalty Chance",
93 | value: `${upsertGuildCreditsSettings.workPenaltyChance}`,
94 | inline: true,
95 | }
96 | ),
97 | ],
98 | });
99 | };
100 |
--------------------------------------------------------------------------------
/src/commands/settings/subcommands/ctrlpanel/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ChatInputCommandInteraction,
3 | EmbedBuilder,
4 | PermissionsBitField,
5 | SlashCommandSubcommandBuilder,
6 | } from "discord.js";
7 | import CtrlPanelAPI, {
8 | CtrlPanelAPIError,
9 | } from "../../../../services/CtrlPanelAPI";
10 | import checkPermission from "../../../../utils/checkPermission";
11 | import deferReply from "../../../../utils/deferReply";
12 | import { GuildNotFoundError } from "../../../../utils/errors";
13 | import logger from "../../../../utils/logger";
14 | import sendResponse from "../../../../utils/sendResponse";
15 |
16 | export const builder = (command: SlashCommandSubcommandBuilder) => {
17 | return command
18 | .setName("ctrlpanel")
19 | .setDescription("Ctrlpanel.gg API")
20 | .addStringOption((option) =>
21 | option
22 | .setName("scheme")
23 | .setDescription("API protocol")
24 | .setRequired(true)
25 | .addChoices(
26 | { name: "HTTPS (secure)", value: "https" },
27 | { name: "HTTP (insecure)", value: "http" }
28 | )
29 | )
30 | .addStringOption((option) =>
31 | option.setName("domain").setDescription("API domain").setRequired(true)
32 | )
33 | .addStringOption((option) =>
34 | option.setName("token").setDescription("API Token").setRequired(true)
35 | );
36 | };
37 |
38 | export const execute = async (interaction: ChatInputCommandInteraction) => {
39 | const { guild, options, user } = interaction;
40 |
41 | await deferReply(interaction, true);
42 | checkPermission(interaction, PermissionsBitField.Flags.ManageGuild);
43 | if (!guild) throw new GuildNotFoundError();
44 |
45 | const scheme = options.getString("scheme", true);
46 | const domain = options.getString("domain", true);
47 | const tokenData = options.getString("token", true);
48 | if (!scheme || !domain || !tokenData)
49 | throw new Error("Scheme, domain, and token must be set");
50 |
51 | const ctrlPanelAPI = new CtrlPanelAPI(guild);
52 |
53 | try {
54 | await ctrlPanelAPI.updateApiCredentials(scheme, domain, tokenData);
55 |
56 | const embedSuccess = new EmbedBuilder()
57 | .setAuthor({
58 | name: "Configuration of Ctrlpanel.gg",
59 | iconURL: "https://ctrlpanel.gg/img/controlpanel.png",
60 | })
61 | .setColor(process.env.EMBED_COLOR_SUCCESS)
62 | .setFooter({
63 | text: `Successfully configured by ${user.username}`,
64 | iconURL: user.displayAvatarURL(),
65 | })
66 | .setTimestamp()
67 | .setDescription(`API Address: \`${scheme}://${domain}\``);
68 |
69 | await sendResponse(interaction, { embeds: [embedSuccess] });
70 | } catch (error: unknown) {
71 | if (error instanceof CtrlPanelAPIError) {
72 | logger.error("CtrlPanelAPI error:", error.message);
73 | throw error;
74 | }
75 | }
76 | };
77 |
--------------------------------------------------------------------------------
/src/commands/settings/subcommands/quotes/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ChatInputCommandInteraction,
3 | EmbedBuilder,
4 | PermissionsBitField,
5 | SlashCommandSubcommandBuilder,
6 | } from "discord.js";
7 | import prisma from "../../../../handlers/prisma";
8 | import checkPermission from "../../../../utils/checkPermission";
9 | import deferReply from "../../../../utils/deferReply";
10 | import { GuildNotFoundError } from "../../../../utils/errors";
11 | import sendResponse from "../../../../utils/sendResponse";
12 |
13 | export const builder = (command: SlashCommandSubcommandBuilder) => {
14 | return command
15 | .setName("quotes")
16 | .setDescription("Configure quotes module")
17 | .addBooleanOption((option) =>
18 | option.setName("status").setDescription("Status").setRequired(true)
19 | )
20 | .addChannelOption((option) =>
21 | option.setName("channel").setDescription("channel").setRequired(true)
22 | );
23 | };
24 |
25 | export const execute = async (interaction: ChatInputCommandInteraction) => {
26 | const { guild, options, user } = interaction;
27 |
28 | await deferReply(interaction, true);
29 | checkPermission(interaction, PermissionsBitField.Flags.ManageGuild);
30 | if (!guild) throw new GuildNotFoundError();
31 |
32 | const quoteStatus = options.getBoolean("status", true);
33 | const quoteChannel = options.getChannel("channel", true);
34 |
35 | const upsertGuildQuotesSettings = await prisma.guildQuotesSettings.upsert({
36 | where: {
37 | id: guild.id,
38 | },
39 | update: {
40 | quoteChannelId: quoteChannel.id,
41 | status: quoteStatus,
42 | },
43 | create: {
44 | id: guild.id,
45 | quoteChannelId: quoteChannel.id,
46 | status: quoteStatus,
47 | guildSettings: {
48 | connectOrCreate: {
49 | where: {
50 | id: guild.id,
51 | },
52 | create: {
53 | id: guild.id,
54 | },
55 | },
56 | },
57 | },
58 | });
59 |
60 | const embedSuccess = new EmbedBuilder()
61 | .setAuthor({
62 | name: "Configuration of Quotes",
63 | })
64 | .setColor(process.env.EMBED_COLOR_SUCCESS)
65 | .setFooter({
66 | text: `Successfully configured by ${user.username}`,
67 | iconURL: user.displayAvatarURL(),
68 | })
69 | .setTimestamp();
70 |
71 | await sendResponse(interaction, {
72 | embeds: [
73 | embedSuccess
74 | .setDescription("Configuration updated successfully!")
75 | .addFields({
76 | name: "Status",
77 | value: `${upsertGuildQuotesSettings.status}`,
78 | inline: true,
79 | })
80 | .addFields({
81 | name: "Channel ID",
82 | value: `${upsertGuildQuotesSettings.quoteChannelId}`,
83 | inline: true,
84 | }),
85 | ],
86 | });
87 | };
88 |
--------------------------------------------------------------------------------
/src/commands/shop/index.ts:
--------------------------------------------------------------------------------
1 | import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
2 | import * as ctrlpanel from "./subcommands/ctrlpanel";
3 |
4 | import {
5 | SubcommandHandlers,
6 | executeSubcommand,
7 | } from "../../handlers/executeSubcommand";
8 |
9 | const subcommandHandlers: SubcommandHandlers = {
10 | ctrlpanel: ctrlpanel.execute,
11 | };
12 |
13 | export const builder = new SlashCommandBuilder()
14 | .setName("shop")
15 | .setDescription("Guild shop")
16 | .setDMPermission(false)
17 | .addSubcommand(ctrlpanel.builder);
18 |
19 | export const execute = async (interaction: ChatInputCommandInteraction) => {
20 | await executeSubcommand(interaction, subcommandHandlers);
21 | };
22 |
--------------------------------------------------------------------------------
/src/commands/shop/subcommands/ctrlpanel.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ActionRowBuilder,
3 | ButtonBuilder,
4 | ButtonStyle,
5 | ChatInputCommandInteraction,
6 | EmbedBuilder,
7 | Message,
8 | SlashCommandSubcommandBuilder,
9 | } from "discord.js";
10 | import { v4 as uuidv4 } from "uuid";
11 | import CreditsManager from "../../../handlers/CreditsManager";
12 | import CtrlPanelAPI from "../../../services/CtrlPanelAPI";
13 | import deferReply from "../../../utils/deferReply";
14 | import { GuildNotFoundError } from "../../../utils/errors";
15 | import sendResponse from "../../../utils/sendResponse";
16 |
17 | const creditsManager = new CreditsManager();
18 |
19 | export const builder = (command: SlashCommandSubcommandBuilder) => {
20 | return command
21 | .setName("ctrlpanel")
22 | .setDescription("Buy cpgg power.")
23 | .addIntegerOption((option) =>
24 | option
25 | .setName("withdraw")
26 | .setDescription("How much credits you want to withdraw.")
27 | .setRequired(true)
28 | .setMinValue(100)
29 | .setMaxValue(999999)
30 | );
31 | };
32 |
33 | export const execute = async (interaction: ChatInputCommandInteraction) => {
34 | const { options, guild, user, client } = interaction;
35 |
36 | await deferReply(interaction, true);
37 | if (!guild) throw new GuildNotFoundError();
38 |
39 | const ctrlPanelAPI = new CtrlPanelAPI(guild);
40 | const withdrawalAmount = options.getInteger("withdraw", true);
41 | await creditsManager.take(guild, user, withdrawalAmount);
42 |
43 | const voucherCode = uuidv4();
44 | const { redeemUrl } = await ctrlPanelAPI.generateVoucher(
45 | voucherCode,
46 | withdrawalAmount,
47 | 1
48 | );
49 |
50 | const userDM = await client.users.fetch(user.id);
51 | const dmEmbed = new EmbedBuilder()
52 | .setTitle(":shopping_cart:︱CPGG")
53 | .setDescription(`This voucher was generated in guild: **${guild.name}**.`)
54 | .setTimestamp()
55 | .addFields({
56 | name: "💶 Credits",
57 | value: `${withdrawalAmount}`,
58 | inline: true,
59 | })
60 | .setColor(process.env.EMBED_COLOR_SUCCESS);
61 |
62 | const redemptionButton = new ButtonBuilder()
63 | .setLabel("Redeem it here")
64 | .setStyle(ButtonStyle.Link)
65 | .setEmoji("🏦")
66 | .setURL(redeemUrl);
67 |
68 | const actionRow = new ActionRowBuilder().addComponents(
69 | redemptionButton
70 | );
71 |
72 | const dmMessage: Message = await userDM.send({
73 | embeds: [dmEmbed],
74 | components: [actionRow],
75 | });
76 |
77 | const interactionEmbed = new EmbedBuilder()
78 | .setTitle(":shopping_cart:︱CPGG")
79 | .setDescription(`I have sent you the code in [DM](${dmMessage.url})!`)
80 | .setTimestamp()
81 | .setColor(process.env.EMBED_COLOR_SUCCESS);
82 |
83 | await sendResponse(interaction, { embeds: [interactionEmbed] });
84 | };
85 |
--------------------------------------------------------------------------------
/src/commands/utils/index.ts:
--------------------------------------------------------------------------------
1 | import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
2 | import {
3 | SubcommandHandlers,
4 | executeSubcommand,
5 | } from "../../handlers/executeSubcommand";
6 | import * as about from "./subcommands/about";
7 | import * as avatar from "./subcommands/avatar";
8 |
9 | const subcommandHandlers: SubcommandHandlers = {
10 | about: about.execute,
11 | avatar: avatar.execute,
12 | };
13 |
14 | export const builder = new SlashCommandBuilder()
15 | .setName("utils")
16 | .setDescription("Common utility.")
17 | .addSubcommand(about.builder)
18 | .addSubcommand(avatar.builder);
19 |
20 | export const execute = async (interaction: ChatInputCommandInteraction) => {
21 | await executeSubcommand(interaction, subcommandHandlers);
22 | };
23 |
--------------------------------------------------------------------------------
/src/commands/utils/subcommands/about/index.ts:
--------------------------------------------------------------------------------
1 | import { formatDuration, intervalToDuration, subMilliseconds } from "date-fns";
2 | import {
3 | ActionRowBuilder,
4 | ButtonBuilder,
5 | ButtonStyle,
6 | CommandInteraction,
7 | EmbedBuilder,
8 | SlashCommandSubcommandBuilder,
9 | } from "discord.js";
10 | import deferReply from "../../../../utils/deferReply";
11 | import { GuildNotFoundError } from "../../../../utils/errors";
12 | import sendResponse from "../../../../utils/sendResponse";
13 |
14 | export const builder = (command: SlashCommandSubcommandBuilder) => {
15 | return command
16 | .setName("about")
17 | .setDescription("Get information about the bot and its hosting");
18 | };
19 |
20 | export const execute = async (interaction: CommandInteraction) => {
21 | const { user, guild, client } = interaction;
22 |
23 | await deferReply(interaction, false);
24 | if (!guild) throw new GuildNotFoundError();
25 |
26 | const guildCount = client.guilds.cache.size;
27 | const memberCount = client.guilds.cache.reduce(
28 | (a, g) => a + g.memberCount,
29 | 0
30 | );
31 | const version = process.env.npm_package_version;
32 |
33 | const buttons = new ActionRowBuilder().addComponents(
34 | new ButtonBuilder()
35 | .setLabel("Documentation")
36 | .setStyle(ButtonStyle.Link)
37 | .setEmoji("📚")
38 | .setURL("https://s.zyner.org/xyter-docs"),
39 | new ButtonBuilder()
40 | .setLabel("Discord Server")
41 | .setStyle(ButtonStyle.Link)
42 | .setEmoji("💬")
43 | .setURL("https://s.zyner.org/discord")
44 | );
45 |
46 | const uptimeDuration = intervalToDuration({
47 | start: subMilliseconds(new Date(), client.uptime),
48 | end: new Date(),
49 | });
50 | const uptimeString = formatDuration(uptimeDuration);
51 |
52 | const botDescription = `This bot, developed by [**Zyner**](https://zyner.org), serves **${guildCount}** servers and has a vast user base of **${memberCount}**. The current version is **${version}**, accessible on [**Zyner Git**](https://git.zyner.org/zyner/xyter/bot). It has been active since the last restart, with an uptime of **${uptimeString}**.`;
53 |
54 | const interactionEmbed = new EmbedBuilder()
55 | .setDescription(botDescription)
56 | .setTimestamp()
57 | .setAuthor({ name: "About Xyter" })
58 | .setColor(process.env.EMBED_COLOR_SUCCESS)
59 | .setFooter({
60 | text: `Requested by ${user.username}`,
61 | iconURL: user.displayAvatarURL(),
62 | });
63 |
64 | await sendResponse(interaction, {
65 | embeds: [interactionEmbed],
66 | components: [buttons],
67 | });
68 | };
69 |
--------------------------------------------------------------------------------
/src/commands/utils/subcommands/avatar/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ChatInputCommandInteraction,
3 | EmbedBuilder,
4 | SlashCommandSubcommandBuilder
5 | } from "discord.js";
6 | import deferReply from "../../../../utils/deferReply";
7 | import sendResponse from "../../../../utils/sendResponse";
8 |
9 | export const builder = (command: SlashCommandSubcommandBuilder) => {
10 | return command
11 | .setName("avatar")
12 | .setDescription("Display someones avatar")
13 | .addUserOption((option) =>
14 | option
15 | .setName("user")
16 | .setDescription("The user whose avatar you want to check")
17 | );
18 | };
19 |
20 | export const execute = async (interaction: ChatInputCommandInteraction) => {
21 | const { options, user } = interaction;
22 |
23 | await deferReply(interaction, false);
24 | const userOption = options.getUser("user");
25 | const targetUser = userOption || user;
26 |
27 | const embed = new EmbedBuilder()
28 | .setAuthor({
29 | name: `${targetUser.username}'s Profile Picture`,
30 | iconURL: targetUser.displayAvatarURL(),
31 | })
32 | .setTimestamp(new Date())
33 | .setFooter({
34 | text: `Requested by ${user.username}`,
35 | iconURL: user.displayAvatarURL(),
36 | });
37 |
38 | const avatarUrl = targetUser.displayAvatarURL();
39 |
40 | await sendResponse(interaction, {
41 | embeds: [
42 | embed
43 | .setDescription(
44 | userOption
45 | ? `You can also [download it here](${avatarUrl})!`
46 | : `Your avatar is available to [download here](${avatarUrl}).`
47 | )
48 | .setThumbnail(avatarUrl)
49 | .setColor(process.env.EMBED_COLOR_SUCCESS),
50 | ],
51 | });
52 | };
53 |
--------------------------------------------------------------------------------
/src/events/guildCreate/index.ts:
--------------------------------------------------------------------------------
1 | import { Guild } from "discord.js";
2 | import upsertGuildMember from "../../helpers/upsertGuildMember";
3 | import { IEventOptions } from "../../interfaces/EventOptions";
4 | import logger from "../../utils/logger";
5 |
6 | export const options: IEventOptions = {
7 | type: "on",
8 | };
9 |
10 | export const execute = async (guild: Guild) => {
11 | try {
12 | const { user } = await guild.fetchOwner();
13 | await upsertGuildMember(guild, user);
14 | } catch (error) {
15 | logger.error(`Error executing guild member fetch: ${error}`);
16 | // Handle the error appropriately (e.g., sending an error message, etc.)
17 | }
18 | };
19 |
--------------------------------------------------------------------------------
/src/events/guildDelete/index.ts:
--------------------------------------------------------------------------------
1 | import { Guild } from "discord.js";
2 | import prisma from "../../handlers/prisma";
3 | import { IEventOptions } from "../../interfaces/EventOptions";
4 | import logger from "../../utils/logger";
5 |
6 | export const options: IEventOptions = {
7 | type: "on",
8 | };
9 |
10 | export const execute = async (guild: Guild) => {
11 | const guildId = guild.id; // Assuming guild.id is the unique ID of the guild
12 |
13 | try {
14 | // Delete the Guild model
15 | await prisma.guild.deleteMany({
16 | where: {
17 | id: guildId,
18 | },
19 | });
20 |
21 | logger.info(`Deleted guild and related records with ID: ${guildId}`);
22 | } catch (error) {
23 | logger.error(`Error executing guild deletion: ${error}`);
24 | // Handle the error appropriately (e.g., logging, sending an error message, etc.)
25 | }
26 | };
27 |
--------------------------------------------------------------------------------
/src/events/guildMemberAdd/index.ts:
--------------------------------------------------------------------------------
1 | import { GuildMember } from "discord.js";
2 | import handleGuildMemberJoin from "../../handlers/handleGuildMemberJoin";
3 | import { IEventOptions } from "../../interfaces/EventOptions";
4 | import logger from "../../utils/logger";
5 |
6 | export const options: IEventOptions = {
7 | type: "on",
8 | };
9 |
10 | export const execute = async (member: GuildMember) => {
11 | const { user, guild } = member;
12 |
13 | logger.info({
14 | message: `User: ${user.tag} (${user.id}) joined guild: ${guild.name} (${guild.id})`,
15 | guild,
16 | user,
17 | });
18 |
19 | await handleGuildMemberJoin(guild, user);
20 | };
21 |
--------------------------------------------------------------------------------
/src/events/guildMemberRemove/index.ts:
--------------------------------------------------------------------------------
1 | import { GuildMember } from "discord.js";
2 | import prisma from "../../handlers/prisma";
3 | import { IEventOptions } from "../../interfaces/EventOptions";
4 | import logger from "../../utils/logger";
5 |
6 | export const options: IEventOptions = {
7 | type: "on",
8 | };
9 |
10 | export const execute = async (member: GuildMember) => {
11 | const { user, guild } = member;
12 |
13 | try {
14 | await prisma.guildMember.delete({
15 | where: {
16 | guildId_userId: {
17 | guildId: guild.id,
18 | userId: user.id,
19 | },
20 | },
21 | });
22 | } catch (error) {
23 | logger.error(`Error deleting guild member: ${error}`);
24 | // Handle the error appropriately (e.g., sending an error message, etc.)
25 | }
26 | };
27 |
--------------------------------------------------------------------------------
/src/events/interactionCreate/index.ts:
--------------------------------------------------------------------------------
1 | import { BaseInteraction } from "discord.js";
2 | import upsertGuildMember from "../../helpers/upsertGuildMember";
3 | import { IEventOptions } from "../../interfaces/EventOptions";
4 | import logger from "../../utils/logger";
5 | import button from "./interactionTypes/button";
6 | import handleCommandInteraction from "./interactionTypes/handleCommandInteraction";
7 |
8 | export const options: IEventOptions = {
9 | type: "on",
10 | };
11 |
12 | export async function execute(interaction: BaseInteraction) {
13 | const { guild, user } = interaction;
14 |
15 | logInteraction();
16 |
17 | if (guild) {
18 | await upsertGuildMember(guild, user);
19 | }
20 |
21 | if (interaction.isCommand()) {
22 | await handleCommandInteraction(interaction);
23 | } else if (interaction.isButton()) {
24 | await button(interaction);
25 | } else {
26 | logError("Unknown interaction type");
27 | }
28 |
29 | function logInteraction() {
30 | logger.verbose({
31 | message: `New interaction created: ${interaction.id} by: ${user.tag} (${user.id})`,
32 | interactionId: interaction.id,
33 | userId: user.id,
34 | guildId: guild?.id,
35 | });
36 | }
37 |
38 | function logError(errorMessage: string) {
39 | logger.error({
40 | message: errorMessage,
41 | error: new Error(errorMessage),
42 | interactionId: interaction.id,
43 | userId: user.id,
44 | guildId: guild?.id,
45 | });
46 | throw new Error(errorMessage);
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/events/interactionCreate/interactionTypes/button/index.ts:
--------------------------------------------------------------------------------
1 | import { ButtonInteraction } from "discord.js";
2 | import interactionErrorHandler from "../../../../handlers/interactionErrorHandler";
3 |
4 | export default async function handleButtonInteraction(
5 | interaction: ButtonInteraction
6 | ) {
7 | const { customId } = interaction;
8 |
9 | const currentButton = await import(`../../../buttons/${customId}`);
10 |
11 | if (!currentButton) {
12 | throw new Error(`Unknown button ${customId}`);
13 | }
14 |
15 | try {
16 | await currentButton.execute(interaction);
17 | } catch (error) {
18 | await interactionErrorHandler(interaction, error);
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/events/interactionCreate/interactionTypes/handleCommandInteraction/handlers/handleCooldown.ts:
--------------------------------------------------------------------------------
1 | import { Cooldown } from "@prisma/client";
2 | import { formatDistanceToNow } from "date-fns";
3 | import {
4 | ActionRowBuilder,
5 | ButtonBuilder,
6 | ButtonStyle,
7 | CommandInteraction,
8 | EmbedBuilder,
9 | } from "discord.js";
10 | import sendResponse from "../../../../../utils/sendResponse";
11 |
12 | export default async function handleCooldown(
13 | interaction: CommandInteraction,
14 | guildCooldown: Cooldown | null,
15 | userCooldown: Cooldown | null,
16 | guildMemberCooldown: Cooldown | null
17 | ) {
18 | const cooldown = guildCooldown || userCooldown || guildMemberCooldown;
19 |
20 | if (!cooldown || !cooldown.expiresAt) {
21 | return;
22 | }
23 |
24 | const timeLeft = formatDistanceToNow(cooldown.expiresAt, {
25 | includeSeconds: true,
26 | });
27 |
28 | const buttons = createButtons();
29 |
30 | const cooldownEmbed = createCooldownEmbed(timeLeft, cooldown.id);
31 |
32 | const response = {
33 | embeds: [cooldownEmbed],
34 | components: [buttons],
35 | ephemeral: true,
36 | };
37 |
38 | await sendResponse(interaction, response);
39 | }
40 |
41 | function createButtons() {
42 | return new ActionRowBuilder().addComponents(
43 | new ButtonBuilder()
44 | .setLabel("Report Problem")
45 | .setStyle(ButtonStyle.Link)
46 | .setEmoji("✏️")
47 | .setURL("https://s.zyner.org/discord")
48 | );
49 | }
50 |
51 | function createCooldownEmbed(timeLeft: string, cooldownId: string) {
52 | return new EmbedBuilder()
53 | .setAuthor({ name: "⚠️ | Request Failed" })
54 | .setDescription(
55 | `Sorry, but you're currently on cooldown. Please try again later.\n\nRemaining cooldown time: ${timeLeft}`
56 | )
57 | .setColor("#FF6699")
58 | .setTimestamp()
59 | .setFooter({ text: `Cooldown ID: ${cooldownId}` });
60 | }
61 |
--------------------------------------------------------------------------------
/src/events/interactionCreate/interactionTypes/handleCommandInteraction/handlers/handleUnavailableCommand.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ActionRowBuilder,
3 | ButtonBuilder,
4 | ButtonStyle,
5 | CommandInteraction,
6 | EmbedBuilder,
7 | } from "discord.js";
8 | import logger from "../../../../../utils/logger";
9 | import sendResponse from "../../../../../utils/sendResponse";
10 |
11 | export default async function handleUnavailableCommand(
12 | interaction: CommandInteraction,
13 | commandName: string
14 | ) {
15 | const commandErrorMessage = `Command '${commandName}' is unavailable`;
16 | logger.error(commandErrorMessage);
17 |
18 | const errorEmbed = new EmbedBuilder()
19 | .setAuthor({ name: "⚠️ | Request Failed" })
20 | .setDescription("Sorry, the command is currently unavailable.")
21 | .setColor("#FFCC66")
22 | .setTimestamp();
23 |
24 | const buttons = new ActionRowBuilder().addComponents(
25 | new ButtonBuilder()
26 | .setLabel("Report Problem")
27 | .setStyle(ButtonStyle.Link)
28 | .setEmoji("✏️")
29 | .setURL("https://s.zyner.org/discord")
30 | );
31 |
32 | const response = {
33 | embeds: [errorEmbed],
34 | components: [buttons],
35 | };
36 |
37 | await sendResponse(interaction, response);
38 | }
39 |
--------------------------------------------------------------------------------
/src/events/interactionCreate/interactionTypes/handleCommandInteraction/index.ts:
--------------------------------------------------------------------------------
1 | import { CommandInteraction } from "discord.js";
2 | import { default as CooldownManager } from "../../../../handlers/CooldownManager";
3 | import interactionErrorHandler from "../../../../handlers/interactionErrorHandler";
4 | import generateCooldownName from "../../../../helpers/generateCooldownName";
5 | import handleCooldown from "./handlers/handleCooldown";
6 | import handleUnavailableCommand from "./handlers/handleUnavailableCommand";
7 |
8 | // Create a map to store locks for each identifier (guild ID + user ID + cooldown item)
9 | const commandLocks = new Map();
10 |
11 | const cooldownManager = new CooldownManager();
12 |
13 | export default async function handleCommandInteraction(
14 | interaction: CommandInteraction
15 | ) {
16 | if (!interaction.isCommand()) {
17 | return;
18 | }
19 |
20 | const { client, commandName, user, guild } = interaction;
21 | const currentCommand = client.commands.get(commandName);
22 |
23 | if (!currentCommand) {
24 | await handleUnavailableCommand(interaction, commandName);
25 | return;
26 | }
27 |
28 | try {
29 | const cooldownItem = await generateCooldownName(interaction);
30 |
31 | // Check if the identifier is already locked
32 | if (commandLocks.has(cooldownItem)) {
33 | throw new Error(
34 | "You are unable to execute the same command simultaneously."
35 | );
36 | }
37 |
38 | const { guildCooldown, userCooldown, guildMemberCooldown } =
39 | await cooldownManager.checkCooldowns(cooldownItem, guild, user);
40 |
41 | if (
42 | (guildCooldown && guildCooldown.expiresAt > new Date()) ||
43 | (userCooldown && userCooldown.expiresAt > new Date()) ||
44 | (guildMemberCooldown && guildMemberCooldown.expiresAt > new Date())
45 | ) {
46 | await handleCooldown(
47 | interaction,
48 | guildCooldown,
49 | userCooldown,
50 | guildMemberCooldown
51 | );
52 | return;
53 | }
54 |
55 | // Create a promise that represents the current command execution
56 | const commandExecutionPromise = currentCommand.execute(interaction);
57 |
58 | // Acquire the lock for the identifier and store the command execution promise
59 | commandLocks.set(cooldownItem, commandExecutionPromise);
60 |
61 | // Wait for the current command execution to complete
62 | await commandExecutionPromise;
63 | } catch (error) {
64 | await interactionErrorHandler(interaction, error);
65 | } finally {
66 | const cooldownItem = await generateCooldownName(interaction);
67 |
68 | // Release the lock for the identifier
69 | commandLocks.delete(cooldownItem);
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/src/events/messageCreate/components/earnCredits.ts:
--------------------------------------------------------------------------------
1 | import { addSeconds } from "date-fns";
2 | import { Channel, ChannelType, Guild, Message, User } from "discord.js";
3 | import CooldownManager from "../../../handlers/CooldownManager";
4 | import CreditsManager from "../../../handlers/CreditsManager";
5 | import logger from "../../../utils/logger";
6 |
7 | const cooldownManager = new CooldownManager();
8 | const creditsManager = new CreditsManager();
9 |
10 | const MINIMUM_LENGTH = 5;
11 |
12 | const cooldownName = "earnCredits";
13 |
14 | export default async (message: Message) => {
15 | const { guild, author, channel, content } = message;
16 |
17 | if (!guild || !isMessageValid(guild, author, channel, content)) {
18 | return;
19 | }
20 |
21 | if (await isUserOnCooldown(guild, author)) {
22 | logger.verbose(
23 | `User "${author.username}" is on cooldown for "${cooldownName}" in guild "${guild.name}"`
24 | );
25 | return;
26 | }
27 |
28 | try {
29 | const filter = (msg: Message) => {
30 | return msg.author == message.author;
31 | };
32 |
33 | if (message.author.bot) return;
34 | if (message.channel.type != ChannelType.GuildText) return;
35 |
36 | const checkTime = 5 * 1000; // Milliseconds
37 | const maxMessageAmount = 3; // Anti Spam Rule, remove 1 credit per message above this value during "checkTime" variable
38 | const amount = 1; //Amount to give if valid
39 |
40 | const penaltyAmount = 2; //Amount to take if invalid
41 |
42 | await message.channel
43 | .awaitMessages({ filter, time: checkTime })
44 | .then(async (messages) => {
45 | // Sounds logic with <= but since it goes down to 0 it should be < since we do not want to add at 3 too since then we add 4
46 | // If user is below "maxMessageAmount"
47 | if (messages.size < maxMessageAmount) {
48 | await creditsManager.give(guild, author, amount);
49 | }
50 |
51 | // Sounds logic with > but since it goes down to 0 it should be >= since we want to remove at 0 too
52 | // If user exceeds "maxMessageAmount"
53 | if (messages.size >= maxMessageAmount) {
54 | await creditsManager.take(guild, author, penaltyAmount);
55 | }
56 |
57 | // When it finished calculating results
58 | if (messages.size === 0) {
59 | await setCooldown(guild, author);
60 | }
61 | });
62 | } catch (error: unknown) {
63 | logger.error(
64 | `Failed to give credits to user ${author.username} in guild ${
65 | guild.name
66 | } when sending a message: ${String(error)}`
67 | );
68 | }
69 | };
70 |
71 | function isMessageValid(
72 | guild: Guild,
73 | author: User,
74 | channel: Channel,
75 | content: string
76 | ): boolean {
77 | return (
78 | guild &&
79 | author &&
80 | !author.bot &&
81 | channel.type === ChannelType.GuildText &&
82 | content.length >= MINIMUM_LENGTH
83 | );
84 | }
85 |
86 | async function isUserOnCooldown(guild: Guild, author: User): Promise {
87 | const cooldownActive = await cooldownManager.checkCooldown(
88 | cooldownName,
89 | guild,
90 | author
91 | );
92 |
93 | if (!cooldownActive) return false;
94 |
95 | return cooldownActive.expiresAt > new Date();
96 | }
97 |
98 | async function setCooldown(guild: Guild, user: User) {
99 | await cooldownManager.setCooldown(
100 | cooldownName,
101 | guild,
102 | user,
103 | addSeconds(new Date(), 5)
104 | );
105 | }
106 |
--------------------------------------------------------------------------------
/src/events/messageCreate/index.ts:
--------------------------------------------------------------------------------
1 | import { Message } from "discord.js";
2 | import { IEventOptions } from "../../interfaces/EventOptions";
3 | import earnCredits from "./components/earnCredits";
4 |
5 | export const options: IEventOptions = {
6 | type: "on",
7 | };
8 |
9 | export const execute = async (message: Message) => {
10 | await earnCredits(message);
11 | };
12 |
--------------------------------------------------------------------------------
/src/events/rateLimit/index.ts:
--------------------------------------------------------------------------------
1 | import { Client } from "discord.js";
2 | import { IEventOptions } from "../../interfaces/EventOptions";
3 | import logger from "../../utils/logger";
4 |
5 | export const options: IEventOptions = {
6 | type: "on",
7 | };
8 |
9 | export const execute = (client: Client) => {
10 | const clientTag = client?.user?.tag ?? "unknown";
11 | logger.info(`Discord API client (${clientTag}) is rate-limited.`);
12 | };
13 |
--------------------------------------------------------------------------------
/src/events/ready/importOldData.ts:
--------------------------------------------------------------------------------
1 | // @ts-nocheck
2 | /* eslint-disable no-loops/no-loops */
3 | import { ChannelType, Client } from "discord.js";
4 | import CreditsManager from "../../handlers/CreditsManager";
5 | import prisma from "../../handlers/prisma";
6 | import logger from "../../utils/logger";
7 |
8 | const creditsManager = new CreditsManager();
9 |
10 | export default async (client: Client) => {
11 | try {
12 | // Fetch all guilds the bot is a member of
13 | const guilds = client.guilds.cache.values();
14 |
15 | for await (const guild of guilds) {
16 | // if (guild.name === "Zyner Infrastructure") continue;
17 |
18 | // if (guild.memberCount > 200) {
19 | // logger.info(`Skipped guild: ${guild.name}`);
20 | // continue;
21 | // }
22 |
23 | const isDone = await prisma.importOldData.findUnique({
24 | where: { id: guild.id },
25 | });
26 |
27 | if (isDone && isDone.done) continue;
28 |
29 | // Fetch all channels in the guild
30 | const channels = guild.channels.cache.filter(
31 | (channel) => channel.type === ChannelType.GuildText
32 | );
33 |
34 | // Object to store message counts per user
35 | const messageCounts: unknown = {};
36 |
37 | for await (const [, channel] of channels) {
38 | if (channel.type !== ChannelType.GuildText) continue;
39 |
40 | let beforeMessageID = null;
41 | let messagesFetched = 0;
42 |
43 | while (true) {
44 | const fetchOptions: never = { limit: 100, before: beforeMessageID };
45 |
46 | try {
47 | const messages = await channel.messages.fetch(fetchOptions);
48 |
49 | for await (const [, message] of messages) {
50 | const userId = message.author.id;
51 |
52 | logger.debug(message.id);
53 |
54 | if (message.author.bot) continue;
55 |
56 | // Increment message count for the user
57 | if (!messageCounts[userId]) {
58 | messageCounts[userId] = 1;
59 | } else {
60 | messageCounts[userId]++;
61 | logger.silly(
62 | `Guild: ${message.guild.name} User: ${message.author.username} => ${messageCounts[userId]}`
63 | );
64 | }
65 | }
66 |
67 | messagesFetched += messages.size;
68 |
69 | if (messages.size < 100) {
70 | break;
71 | }
72 |
73 | const importConfig = await prisma.importOldData.upsert({
74 | where: { id: messages.last().guild.id },
75 | update: { beforeMessageId: messages.last()?.id },
76 | create: {
77 | id: messages.last().guild.id,
78 | beforeMessageId: messages.last()?.id,
79 | },
80 | });
81 |
82 | logger.error(importConfig.beforeMessageId);
83 | beforeMessageID = importConfig.beforeMessageId;
84 | } catch (error) {
85 | console.error(`Error fetching messages in ${channel.name}:`, error);
86 | // Handle rate limit here if needed
87 | // You can use the error object to determine the type of error and handle it accordingly
88 | // For example, you can check if the error is a rate limit error and implement a delay before retrying
89 |
90 | if (error.code === 429) {
91 | logger.error("RATE LIMIT");
92 | // Rate limit hit, wait for the specified duration and retry
93 | const retryAfter = error.retryAfter;
94 | logger.warn(`Rate limited. Retrying in ${retryAfter}ms...`);
95 | console.log(retryAfter);
96 | await new Promise((resolve) => setTimeout(resolve, retryAfter));
97 | continue; // Retry the fetch
98 | }
99 |
100 | if (error.code === 10013) continue;
101 | if (error.code === 50001) break;
102 | if (error.code === 10007) logger.error("Unknown user");
103 | logger.error(error.code);
104 | }
105 | }
106 | }
107 |
108 | // Log message counts for the guild
109 | logger.info(`Message Counts for Guild: ${guild.name}`);
110 | await prisma.importOldData.upsert({
111 | where: { id: guild.id },
112 | update: { done: true },
113 | create: {
114 | id: guild.id,
115 | done: true,
116 | },
117 | });
118 | for (const userId in messageCounts) {
119 | if (messageCounts.hasOwnProperty(userId)) {
120 | try {
121 | const member = await guild.members.fetch(userId);
122 | if (!member) continue; // Skip unknown members
123 |
124 | await creditsManager.set(
125 | member.guild,
126 | member.user,
127 | messageCounts[userId]
128 | );
129 |
130 | logger.info(
131 | `${member?.user.username}: ${messageCounts[userId]} messages`
132 | );
133 | } catch (error: unknown) {
134 | if (error.code === 429) {
135 | logger.error("RATE LIMIT");
136 | // Rate limit hit, wait for the specified duration and retry
137 | const retryAfter = error.retryAfter;
138 | logger.warn(`Rate limited. Retrying in ${retryAfter}ms...`);
139 | console.log(retryAfter);
140 | await new Promise((resolve) => setTimeout(resolve, retryAfter));
141 | continue; // Retry the fetch
142 | }
143 |
144 | if (error.code === 10013) continue;
145 | if (error.code === 50001) break;
146 | if (error.code === 10007)
147 | logger.error(`Unknown user: ${messageCounts[userId]}`);
148 | logger.error(error.code);
149 | }
150 | 10007;
151 | }
152 | }
153 | }
154 | } catch (error) {
155 | console.error("Error:", error);
156 | }
157 | };
158 |
--------------------------------------------------------------------------------
/src/events/ready/index.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-loops/no-loops */
2 | import { Client } from "discord.js";
3 | import registerCommands from "../../handlers/registerCommands";
4 | import updatePresence from "../../handlers/updatePresence";
5 | import { IEventOptions } from "../../interfaces/EventOptions";
6 | import logger from "../../utils/logger";
7 | import importOldData from "./importOldData";
8 |
9 | export const options: IEventOptions = {
10 | type: "once",
11 | };
12 |
13 | export const execute = async (client: Client) => {
14 | if (!client.user) {
15 | logger.error("Client user unavailable");
16 | throw new Error("Client user unavailable");
17 | }
18 |
19 | logger.info("Connected to Discord!");
20 |
21 | updatePresence(client);
22 | await registerCommands(client);
23 |
24 | if (process.env.IMPORT_DATA_FROM_V1 === "true") {
25 | await importOldData(client);
26 | }
27 | };
28 |
--------------------------------------------------------------------------------
/src/handlers/CooldownManager.ts:
--------------------------------------------------------------------------------
1 | import { Cooldown } from "@prisma/client";
2 | import { Guild, User } from "discord.js";
3 | import logger from "../utils/logger";
4 | import prisma from "./prisma";
5 |
6 | class CooldownManager {
7 | async setCooldown(
8 | cooldownItem: string,
9 | guild: Guild | null,
10 | user: User | null,
11 | expiresAt: Date
12 | ): Promise {
13 | const data = {
14 | cooldownItem,
15 | expiresAt,
16 | guild: guild ? { connect: { id: guild.id } } : undefined,
17 | user: user ? { connect: { id: user.id } } : undefined,
18 | };
19 |
20 | const existingCooldown = await this.checkCooldown(
21 | cooldownItem,
22 | guild,
23 | user
24 | );
25 |
26 | if (existingCooldown) {
27 | await prisma.cooldown.update({
28 | where: {
29 | id: existingCooldown.id,
30 | },
31 | data: {
32 | expiresAt,
33 | },
34 | });
35 | } else {
36 | await prisma.cooldown.create({ data });
37 | }
38 |
39 | if (guild && user) {
40 | logger.verbose(
41 | `Set guild member cooldown: ${cooldownItem} in guild ${guild.id} for user ${user.id}`
42 | );
43 | } else if (guild) {
44 | logger.verbose(
45 | `Set guild cooldown: ${cooldownItem} in guild ${guild.id}`
46 | );
47 | } else if (user) {
48 | logger.verbose(`Set user cooldown: ${cooldownItem} for user ${user.id}`);
49 | }
50 | }
51 |
52 | async checkCooldown(
53 | cooldownItem: string,
54 | guild: Guild | null,
55 | user: User | null
56 | ): Promise {
57 | const start = Date.now();
58 | const where = {
59 | cooldownItem,
60 | guild: guild ? { id: guild.id } : null,
61 | user: user ? { id: user.id } : null,
62 | };
63 | const cooldown = await prisma.cooldown.findFirst({ where });
64 | const duration = Date.now() - start;
65 |
66 | if (guild && user) {
67 | logger.verbose(
68 | `Checked guild member cooldown: ${cooldownItem} in guild ${guild.id} for user ${user.id}. Duration: ${duration}ms`
69 | );
70 | } else if (guild) {
71 | logger.verbose(
72 | `Checked guild cooldown: ${cooldownItem} in guild ${guild.id}. Duration: ${duration}ms`
73 | );
74 | } else if (user) {
75 | logger.verbose(
76 | `Checked user cooldown: ${cooldownItem} for user ${user.id}. Duration: ${duration}ms`
77 | );
78 | }
79 |
80 | return cooldown;
81 | }
82 |
83 | async checkCooldowns(
84 | cooldownItem: string,
85 | guild: Guild | null,
86 | user: User | null
87 | ): Promise<{
88 | guildCooldown: Cooldown | null;
89 | userCooldown: Cooldown | null;
90 | guildMemberCooldown: Cooldown | null;
91 | }> {
92 | const guildCooldown = guild
93 | ? await this.checkCooldown(cooldownItem, guild, null)
94 | : null;
95 | const userCooldown = await this.checkCooldown(cooldownItem, null, user);
96 | const guildMemberCooldown = guild
97 | ? await this.checkCooldown(cooldownItem, guild, user)
98 | : null;
99 |
100 | return { guildCooldown, userCooldown, guildMemberCooldown };
101 | }
102 | }
103 |
104 | export default CooldownManager;
105 |
--------------------------------------------------------------------------------
/src/handlers/CreditsManager.ts:
--------------------------------------------------------------------------------
1 | import { Guild, User } from "discord.js";
2 | import logger from "../utils/logger";
3 | import prisma from "./prisma";
4 |
5 | class CreditsManager {
6 | async validateTransaction(guild: Guild, user: User, amount: number) {
7 | if (!guild) {
8 | throw new Error("Credits are only available for guilds.");
9 | }
10 |
11 | if (amount <= 0) {
12 | throw new Error("You cannot make a transaction below 1 credit.");
13 | }
14 |
15 | if (amount > 2147483647) {
16 | throw new Error("The maximum allowed credits is 2,147,483,647.");
17 | }
18 |
19 | if (user.bot) {
20 | throw new Error("Bots cannot participate in transactions.");
21 | }
22 | }
23 |
24 | async balance(guild: Guild, user: User) {
25 | return await prisma.$transaction(async (tx) => {
26 | const recipient = await tx.guildMemberCredit.upsert({
27 | update: {},
28 | create: {
29 | guildMember: {
30 | connectOrCreate: {
31 | create: {
32 | user: {
33 | connectOrCreate: {
34 | create: { id: user.id },
35 | where: { id: user.id },
36 | },
37 | },
38 | guild: {
39 | connectOrCreate: {
40 | create: { id: guild.id },
41 | where: { id: guild.id },
42 | },
43 | },
44 | },
45 | where: { guildId_userId: { guildId: guild.id, userId: user.id } },
46 | },
47 | },
48 | },
49 | where: {
50 | guildId_userId: {
51 | guildId: guild.id,
52 | userId: user.id,
53 | },
54 | },
55 | });
56 |
57 | if (!recipient) throw new Error("No recipient available");
58 |
59 | return recipient;
60 | });
61 | }
62 |
63 | async give(guild: Guild, user: User, amount: number) {
64 | try {
65 | logger.debug(
66 | `Starting give transaction for guild: ${guild.id}, user: ${user.id}`
67 | );
68 |
69 | const recipient = await prisma.$transaction(async (tx) => {
70 | await this.validateTransaction(guild, user, amount);
71 |
72 | const existingRecipient = await tx.guildMemberCredit.findUnique({
73 | where: {
74 | guildId_userId: {
75 | userId: user.id,
76 | guildId: guild.id,
77 | },
78 | },
79 | });
80 |
81 | if (existingRecipient && existingRecipient.balance > 2147483647) {
82 | throw new Error(
83 | "Oops! That's more credits than the user can have. The maximum allowed is 2,147,483,647."
84 | );
85 | }
86 |
87 | await this.upsertGuildMember(guild, user);
88 |
89 | const recipient = await tx.guildMemberCredit.upsert({
90 | update: {
91 | balance: {
92 | increment: amount,
93 | },
94 | },
95 | create: {
96 | guildMember: {
97 | connectOrCreate: {
98 | create: {
99 | user: {
100 | connectOrCreate: {
101 | create: { id: user.id },
102 | where: { id: user.id },
103 | },
104 | },
105 | guild: {
106 | connectOrCreate: {
107 | create: { id: guild.id },
108 | where: { id: guild.id },
109 | },
110 | },
111 | },
112 | where: {
113 | guildId_userId: {
114 | guildId: guild.id,
115 | userId: user.id,
116 | },
117 | },
118 | },
119 | },
120 | balance: amount,
121 | },
122 | where: {
123 | guildId_userId: {
124 | guildId: guild.id,
125 | userId: user.id,
126 | },
127 | },
128 | });
129 |
130 | return recipient;
131 | });
132 |
133 | logger.debug(
134 | `Give transaction completed for guild: ${guild.id}, user: ${user.id}`
135 | );
136 |
137 | return recipient;
138 | } catch (error) {
139 | logger.error(`Error in give transaction for user: ${user.id}`, error);
140 | throw error;
141 | }
142 | }
143 |
144 | async take(guild: Guild, user: User, amount: number) {
145 | try {
146 | logger.debug(
147 | `Starting take transaction for guild: ${guild.id}, user: ${user.id}`
148 | );
149 |
150 | const recipient = await prisma.$transaction(async (tx) => {
151 | await this.validateTransaction(guild, user, amount);
152 |
153 | const existingRecipient = await tx.guildMemberCredit.findUnique({
154 | where: {
155 | guildId_userId: {
156 | userId: user.id,
157 | guildId: guild.id,
158 | },
159 | },
160 | });
161 |
162 | if (!existingRecipient || existingRecipient.balance < amount) {
163 | throw new Error("Insufficient credits for the transaction.");
164 | }
165 |
166 | await this.upsertGuildMember(guild, user);
167 |
168 | const recipient = await tx.guildMemberCredit.upsert({
169 | update: {
170 | balance: {
171 | decrement: amount,
172 | },
173 | },
174 | create: {
175 | guildMember: {
176 | connectOrCreate: {
177 | create: {
178 | user: {
179 | connectOrCreate: {
180 | create: { id: user.id },
181 | where: { id: user.id },
182 | },
183 | },
184 | guild: {
185 | connectOrCreate: {
186 | create: { id: guild.id },
187 | where: { id: guild.id },
188 | },
189 | },
190 | },
191 | where: {
192 | guildId_userId: {
193 | guildId: guild.id,
194 | userId: user.id,
195 | },
196 | },
197 | },
198 | },
199 | balance: -amount,
200 | },
201 | where: {
202 | guildId_userId: {
203 | guildId: guild.id,
204 | userId: user.id,
205 | },
206 | },
207 | });
208 |
209 | return recipient;
210 | });
211 |
212 | logger.debug(
213 | `Take transaction completed for guild: ${guild.id}, user: ${user.id}`
214 | );
215 |
216 | return recipient;
217 | } catch (error) {
218 | logger.error(`Error in take transaction for user: ${user.id}`, error);
219 | throw error;
220 | }
221 | }
222 |
223 | async set(guild: Guild, user: User, amount: number) {
224 | try {
225 | logger.debug(
226 | `Starting set transaction for guild: ${guild.id}, user: ${user.id}`
227 | );
228 |
229 | const recipient = await prisma.$transaction(async (tx) => {
230 | await this.validateTransaction(guild, user, amount);
231 |
232 | await this.upsertGuildMember(guild, user);
233 |
234 | const recipient = await tx.guildMemberCredit.upsert({
235 | update: {
236 | balance: amount,
237 | },
238 | create: {
239 | guildMember: {
240 | connectOrCreate: {
241 | create: {
242 | user: {
243 | connectOrCreate: {
244 | create: { id: user.id },
245 | where: { id: user.id },
246 | },
247 | },
248 | guild: {
249 | connectOrCreate: {
250 | create: { id: guild.id },
251 | where: { id: guild.id },
252 | },
253 | },
254 | },
255 | where: {
256 | guildId_userId: {
257 | guildId: guild.id,
258 | userId: user.id,
259 | },
260 | },
261 | },
262 | },
263 | balance: amount,
264 | },
265 | where: {
266 | guildId_userId: {
267 | guildId: guild.id,
268 | userId: user.id,
269 | },
270 | },
271 | });
272 |
273 | return recipient;
274 | });
275 |
276 | logger.debug(
277 | `Set transaction completed for guild: ${guild.id}, user: ${user.id}`
278 | );
279 |
280 | return recipient;
281 | } catch (error) {
282 | logger.error(`Error in set transaction for user: ${user.id}`, error);
283 | throw error;
284 | }
285 | }
286 |
287 | async transfer(guild: Guild, fromUser: User, toUser: User, amount: number) {
288 | if (fromUser.id === toUser.id) {
289 | throw new Error("The sender and receiver cannot be the same user.");
290 | }
291 |
292 | try {
293 | const fromTransaction = await prisma.guildMemberCredit.findFirst({
294 | where: {
295 | guildId: guild.id,
296 | userId: fromUser.id,
297 | },
298 | });
299 |
300 | if (!fromTransaction) {
301 | throw new Error("Failed to fetch the sender's transaction record.");
302 | }
303 |
304 | const toTransaction = await prisma.guildMemberCredit.findUnique({
305 | where: {
306 | guildId_userId: {
307 | guildId: guild.id,
308 | userId: toUser.id,
309 | },
310 | },
311 | });
312 |
313 | if (!toTransaction) {
314 | console.log({ guildId: guild.id, userId: toUser.id });
315 |
316 | // Create a new transaction record for the recipient with initial balance of 0
317 |
318 | await this.upsertGuildMember(guild, toUser);
319 | prisma.guildMemberCredit.create({
320 | data: {
321 | guildId: guild.id,
322 | userId: toUser.id,
323 | balance: 0,
324 | },
325 | });
326 | }
327 |
328 | const remainingBalance = 2147483647 - amount;
329 |
330 | if (fromTransaction.balance < amount) {
331 | throw new Error("The sender does not have enough credits.");
332 | }
333 |
334 | await this.validateTransaction(guild, toUser, amount);
335 |
336 | let adjustedAmount = amount;
337 | let overflowAmount = 0;
338 |
339 | if (toTransaction && toTransaction.balance + amount > 2147483647) {
340 | adjustedAmount = 2147483647 - toTransaction.balance;
341 | overflowAmount = amount - adjustedAmount;
342 | }
343 |
344 | await prisma.$transaction(async (tx) => {
345 | await tx.guildMemberCredit.update({
346 | where: {
347 | guildId_userId: {
348 | guildId: guild.id,
349 | userId: fromUser.id,
350 | },
351 | },
352 | data: {
353 | balance: {
354 | decrement: amount,
355 | },
356 | },
357 | });
358 |
359 | if (adjustedAmount > 0) {
360 | await tx.guildMemberCredit.upsert({
361 | where: {
362 | guildId_userId: {
363 | guildId: guild.id,
364 | userId: toUser.id,
365 | },
366 | },
367 | create: {
368 | guildId: guild.id,
369 | userId: toUser.id,
370 | balance: adjustedAmount,
371 | },
372 | update: {
373 | balance: {
374 | increment: adjustedAmount,
375 | },
376 | },
377 | });
378 | }
379 |
380 | if (overflowAmount > 0) {
381 | await tx.guildMemberCredit.update({
382 | where: {
383 | guildId_userId: {
384 | guildId: guild.id,
385 | userId: fromUser.id,
386 | },
387 | },
388 | data: {
389 | balance: {
390 | increment: overflowAmount,
391 | },
392 | },
393 | });
394 | }
395 | });
396 |
397 | const updatedFromTransaction = await prisma.guildMemberCredit.findFirst({
398 | where: {
399 | guildId: guild.id,
400 | userId: fromUser.id,
401 | },
402 | });
403 |
404 | const updatedToTransaction = await prisma.guildMemberCredit.findFirst({
405 | where: {
406 | guildId: guild.id,
407 | userId: toUser.id,
408 | },
409 | });
410 |
411 | if (!updatedFromTransaction) {
412 | throw new Error(
413 | "Failed to fetch the updated sender's transaction record."
414 | );
415 | }
416 |
417 | if (!updatedToTransaction) {
418 | throw new Error(
419 | "Failed to fetch the updated recipient's transaction record."
420 | );
421 | }
422 |
423 | const transferredAmount = adjustedAmount;
424 |
425 | return {
426 | transferredAmount,
427 | fromTransaction: updatedFromTransaction,
428 | toTransaction: updatedToTransaction,
429 | };
430 | } catch (error: any) {
431 | logger.error(
432 | `Error in transaction for guild: ${guild.id}, sender: ${fromUser.id}, recipient: ${toUser.id}: ${error.message}`
433 | );
434 | throw error;
435 | }
436 | }
437 |
438 | async topUsers(guild: Guild, userAmount: number) {
439 | return await prisma.$transaction(async (tx) => {
440 | const topUsers = await prisma.guildMemberCredit.findMany({
441 | where: {
442 | guildId: guild.id,
443 | },
444 | orderBy: {
445 | balance: "desc",
446 | },
447 | take: userAmount,
448 | });
449 |
450 | // 2. Verify that there are some top users.
451 | if (!topUsers) throw new Error("No top users found");
452 |
453 | // 3. Return top users.
454 | return topUsers;
455 | });
456 | }
457 |
458 | async upsertGuildMember(guild: Guild, user: User) {
459 | await prisma.guildMember.upsert({
460 | update: {},
461 | create: {
462 | user: {
463 | connectOrCreate: {
464 | create: { id: user.id },
465 | where: { id: user.id },
466 | },
467 | },
468 | guild: {
469 | connectOrCreate: {
470 | create: { id: guild.id },
471 | where: { id: guild.id },
472 | },
473 | },
474 | },
475 | where: {
476 | guildId_userId: {
477 | guildId: guild.id,
478 | userId: user.id,
479 | },
480 | },
481 | });
482 | }
483 | }
484 |
485 | export default CreditsManager;
486 |
--------------------------------------------------------------------------------
/src/handlers/ReputationManager.ts:
--------------------------------------------------------------------------------
1 | import { UserReputation } from "@prisma/client";
2 | import { User } from "discord.js";
3 | import prisma from "./prisma";
4 |
5 | class ReputationManager {
6 | async check(user: User) {
7 | const userData = await prisma.user.upsert({
8 | where: { id: user.id },
9 | update: {},
10 | create: {
11 | id: user.id,
12 | userReputation: {
13 | create: {
14 | positive: 0,
15 | negative: 0,
16 | },
17 | },
18 | },
19 | include: {
20 | userReputation: true,
21 | },
22 | });
23 |
24 | const userReputation = userData.userReputation;
25 |
26 | if (!userReputation) {
27 | return {
28 | total: 0,
29 | positive: 0,
30 | negative: 0,
31 | };
32 | }
33 |
34 | return {
35 | total: userReputation.positive - userReputation.negative,
36 | positive: userReputation.positive,
37 | negative: userReputation.negative,
38 | };
39 | }
40 |
41 | async repute(user: User, type: "positive" | "negative") {
42 | let userReputation: UserReputation | null = null;
43 |
44 | if (type === "positive") {
45 | userReputation = await prisma.userReputation.upsert({
46 | where: { id: user.id },
47 | update: { positive: { increment: 1 } },
48 | create: {
49 | positive: 1,
50 | negative: 0,
51 | user: {
52 | connectOrCreate: {
53 | where: {
54 | id: user.id,
55 | },
56 | create: { id: user.id },
57 | },
58 | },
59 | },
60 | });
61 | }
62 |
63 | if (type === "negative") {
64 | userReputation = await prisma.userReputation.upsert({
65 | where: { id: user.id },
66 | update: { negative: { increment: 1 } },
67 | create: {
68 | positive: 0,
69 | negative: 1,
70 | user: { connect: { id: user.id } },
71 | },
72 | });
73 | }
74 |
75 | return userReputation;
76 | }
77 | }
78 |
79 | export default ReputationManager;
80 |
--------------------------------------------------------------------------------
/src/handlers/executeSubcommand.ts:
--------------------------------------------------------------------------------
1 | import { ChatInputCommandInteraction } from "discord.js";
2 |
3 | export interface SubcommandHandlers {
4 | [subcommand: string]: (
5 | interaction: ChatInputCommandInteraction
6 | ) => Promise;
7 | }
8 |
9 | export interface SubcommandGroupHandlers {
10 | [subcommandGroup: string]: SubcommandHandlers;
11 | }
12 |
13 | export const executeSubcommand = async (
14 | interaction: ChatInputCommandInteraction,
15 | subcommandHandlers: SubcommandHandlers,
16 | subcommandGroupHandlers?: SubcommandGroupHandlers
17 | ) => {
18 | const subcommandGroup = interaction.options.getSubcommandGroup();
19 | if (subcommandGroupHandlers && subcommandGroup) {
20 | const handlers = subcommandGroupHandlers?.[subcommandGroup];
21 | if (handlers) {
22 | await executeSubcommand(interaction, handlers);
23 | return;
24 | } else {
25 | throw new Error(`Subcommand group not found: ${subcommandGroup}`);
26 | }
27 | }
28 |
29 | const subcommand = interaction.options.getSubcommand();
30 | const handler = subcommandHandlers[subcommand];
31 |
32 | if (handler) {
33 | await handler(interaction);
34 | } else {
35 | throw new Error(`Subcommand not found: ${subcommand}`);
36 | }
37 | };
38 |
--------------------------------------------------------------------------------
/src/handlers/handleGuildMemberJoin.ts:
--------------------------------------------------------------------------------
1 | import { Guild, User } from "discord.js";
2 | import upsertGuildMember from "../helpers/upsertGuildMember";
3 | import logger from "../utils/logger";
4 |
5 | const handleGuildMemberJoin = async (guild: Guild, user: User) => {
6 | try {
7 | // Create the user
8 | await upsertGuildMember(guild, user);
9 |
10 | // Example: Logging the guild member join event
11 | logger.info(`User ${user.tag} joined guild ${guild.name} (${guild.id}).`);
12 | } catch (error) {
13 | // Handle any errors that occur during the guild member join event handling
14 | logger.error(`Error handling guild member join: ${error}`);
15 | }
16 | };
17 |
18 | export default handleGuildMemberJoin;
19 |
--------------------------------------------------------------------------------
/src/handlers/interactionErrorHandler.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ActionRowBuilder,
3 | ButtonBuilder,
4 | ButtonInteraction,
5 | ButtonStyle,
6 | CommandInteraction,
7 | EmbedBuilder,
8 | codeBlock,
9 | } from "discord.js";
10 | import sendResponse from "../utils/sendResponse";
11 |
12 | export default async (
13 | interaction: CommandInteraction | ButtonInteraction,
14 | error: unknown
15 | ) => {
16 | if (error instanceof Error) {
17 | const buttons = new ActionRowBuilder().addComponents(
18 | new ButtonBuilder()
19 | .setLabel("Report Problem")
20 | .setStyle(ButtonStyle.Link)
21 | .setEmoji("✏️")
22 | .setURL("https://s.zyner.org/discord")
23 | );
24 |
25 | const errorEmbed = new EmbedBuilder()
26 | .setAuthor({ name: "⚠️ | Request Failed" })
27 | .setDescription(
28 | error.message ??
29 | "An error occurred while processing your request. Please try again later."
30 | )
31 | .setColor("#FFCC66")
32 | .setTimestamp();
33 |
34 | if (process.env.NODE_ENV === "development" && error.stack !== undefined) {
35 | errorEmbed.addFields({
36 | name: "Error Stack",
37 | value: codeBlock(error.stack),
38 | });
39 | }
40 |
41 | const response = {
42 | embeds: [errorEmbed],
43 | components: [buttons],
44 | ephemeral: true,
45 | };
46 |
47 | await sendResponse(interaction, response);
48 | }
49 | };
50 |
--------------------------------------------------------------------------------
/src/handlers/prisma.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "@prisma/client";
2 | import logger from "../utils/logger";
3 |
4 | const prisma = new PrismaClient();
5 | const LATENCY_THRESHOLD = 1000; // Threshold in milliseconds
6 |
7 | prisma.$use(async (params, next) => {
8 | const before = Date.now();
9 | try {
10 | const result = await next(params);
11 | const after = Date.now();
12 | const duration = after - before;
13 |
14 | logger.debug({
15 | message: `Query ${params.model}.${params.action} took ${duration}ms`,
16 | duration,
17 | model: params.model,
18 | action: params.action,
19 | });
20 |
21 | if (duration > LATENCY_THRESHOLD) {
22 | logger.warn({
23 | message: `High latency query: ${params.model}.${params.action}`,
24 | duration,
25 | model: params.model,
26 | action: params.action,
27 | });
28 | }
29 |
30 | return result;
31 | } catch (error) {
32 | logger.error({
33 | message: `Error executing query ${params.model}.${params.action}`,
34 | error,
35 | model: params.model,
36 | action: params.action,
37 | });
38 |
39 | throw error;
40 | }
41 | });
42 |
43 | export default prisma;
44 |
--------------------------------------------------------------------------------
/src/handlers/registerCommands.ts:
--------------------------------------------------------------------------------
1 | import { Client, RESTPostAPIApplicationCommandsJSONBody } from "discord.js";
2 | import { ICommand } from "../interfaces/Command";
3 | import logger from "../utils/logger";
4 | import checkDirectory from "../utils/readDirectory";
5 |
6 | export default async (client: Client) => {
7 | const profiler = logger.startTimer();
8 | const { application } = client;
9 |
10 | if (!application) throw new Error("No application found");
11 |
12 | const builders: RESTPostAPIApplicationCommandsJSONBody[] = [];
13 |
14 | const commandNames = await checkDirectory("commands");
15 |
16 | await Promise.all(
17 | commandNames.map(async (commandName) => {
18 | const commandProfiler = logger.startTimer();
19 |
20 | try {
21 | const command: ICommand = await import(`../commands/${commandName}`);
22 | const commandBuilder = command.builder.toJSON();
23 |
24 | const existingCommand = client.commands.get(commandBuilder.name);
25 | if (existingCommand) {
26 | client.commands.delete(commandBuilder.name);
27 | commandProfiler.done({
28 | message: `Removed existing command '${commandBuilder.name}'`,
29 | commandName,
30 | level: "debug",
31 | });
32 | }
33 |
34 | client.commands.set(commandBuilder.name, command);
35 | builders.push(commandBuilder);
36 |
37 | commandProfiler.done({
38 | commandName,
39 | message: `Registered command '${commandBuilder.name}'`,
40 | level: "debug",
41 | });
42 | } catch (error) {
43 | commandProfiler.done({
44 | message: `Failed to register command '${commandName}'`,
45 | commandName,
46 | error,
47 | level: "error",
48 | });
49 | }
50 | })
51 | );
52 |
53 | await Promise.all([
54 | application.commands.set(builders),
55 | process.env.NODE_ENV === "development"
56 | ? application.commands.set(builders, process.env.DISCORD_GUILD_ID)
57 | : Promise.resolve(),
58 | ]).then(() => {
59 | logger.info({ builders, message: "Registered commands!" });
60 | });
61 |
62 | return profiler.done({
63 | message: "Successfully registered all commands!",
64 | });
65 | };
66 |
--------------------------------------------------------------------------------
/src/handlers/registerEvents.ts:
--------------------------------------------------------------------------------
1 | import { Client } from "discord.js";
2 | import { IEvent } from "../interfaces/Event";
3 | import logger from "../utils/logger";
4 | import checkDirectory from "../utils/readDirectory";
5 |
6 | export default async (client: Client) => {
7 | const profiler = logger.startTimer();
8 |
9 | try {
10 | const eventNames = await checkDirectory("events");
11 |
12 | const importEvent = async (name: string) => {
13 | try {
14 | const event = (await import(`../events/${name}`)) as IEvent;
15 |
16 | const eventExecutor = async (...args: Promise[]) => {
17 | try {
18 | await event.execute(...args);
19 | } catch (error) {
20 | logger.error(`Error occurred in event '${name}':`, error);
21 | }
22 | };
23 |
24 | switch (event.options.type) {
25 | case "once":
26 | client.once(name, eventExecutor);
27 | break;
28 | case "on":
29 | client.on(name, eventExecutor);
30 | break;
31 | default:
32 | throw new Error(`Unknown event type: ${event.options.type}`);
33 | }
34 |
35 | logger.debug({
36 | eventName: name,
37 | type: event.options.type,
38 | message: `Listening to event '${name}'`,
39 | });
40 |
41 | return event;
42 | } catch (error) {
43 | logger.error(
44 | `Error occurred while registering event '${name}':`,
45 | error
46 | );
47 | }
48 | };
49 |
50 | await Promise.all(eventNames.map(importEvent));
51 |
52 | profiler.done({
53 | message: "Successfully listening to all events!",
54 | });
55 | } catch (error) {
56 | logger.error("Error occurred during event registration:", error);
57 | }
58 | };
59 |
--------------------------------------------------------------------------------
/src/handlers/scheduleJobs.ts:
--------------------------------------------------------------------------------
1 | import { Client } from "discord.js";
2 | import schedule from "node-schedule";
3 | import { IJob } from "../interfaces/Job";
4 | import logger from "../utils/logger";
5 | import checkDirectory from "../utils/readDirectory";
6 |
7 | export default async (client: Client) => {
8 | const profiler = logger.startTimer();
9 |
10 | const jobNames = await checkDirectory("jobs");
11 |
12 | const executeJob = async (job: IJob, jobName: string) => {
13 | const jobProfiler = logger.startTimer();
14 | try {
15 | await job.execute(client);
16 | jobProfiler.done({
17 | message: `Successfully executed job '${jobName}'`,
18 | level: "debug",
19 | job,
20 | jobName,
21 | });
22 | } catch (error) {
23 | jobProfiler.done({
24 | message: `Failed executing job '${jobName}'`,
25 | level: "debug",
26 | job,
27 | jobName,
28 | });
29 | }
30 | };
31 |
32 | const importJob = async (jobName: string) => {
33 | try {
34 | const job = (await import(`../jobs/${jobName}`)) as IJob;
35 |
36 | // Check if the bot is already logged in
37 | if (client.readyAt) {
38 | schedule.scheduleJob(job.options.schedule, () => {
39 | executeJob(job, jobName);
40 | });
41 | } else {
42 | // Wait for the bot to be ready before scheduling the job
43 | client.once("ready", () => {
44 | schedule.scheduleJob(job.options.schedule, () => {
45 | executeJob(job, jobName);
46 | });
47 | });
48 | }
49 | } catch (error) {
50 | logger.warn({
51 | jobName,
52 | message: `Failed to schedule job ${jobName}`,
53 | error,
54 | });
55 | }
56 | };
57 |
58 | await Promise.all(jobNames.map(importJob));
59 |
60 | return profiler.done({
61 | message: "Successfully scheduled all jobs!",
62 | });
63 | };
64 |
--------------------------------------------------------------------------------
/src/handlers/updatePresence.ts:
--------------------------------------------------------------------------------
1 | import { ActivitiesOptions, ActivityType, Client } from "discord.js";
2 | import logger from "../utils/logger";
3 |
4 | export default async (client: Client) => {
5 | const { guilds, user } = client;
6 | if (!user) {
7 | logger.error("No user found");
8 | throw new Error("No user found");
9 | }
10 |
11 | const memberCount = guilds.cache.reduce(
12 | (acc, guild) => acc + (guild.memberCount || 0),
13 | 0
14 | );
15 | const guildCount = guilds.cache.size;
16 |
17 | const activities: ActivitiesOptions[] = [
18 | {
19 | name: `${memberCount} users`,
20 | type: ActivityType.Watching,
21 | },
22 | {
23 | name: `${guildCount} servers`,
24 | type: ActivityType.Watching,
25 | },
26 | ];
27 |
28 | const shuffleArray = (array: T[]): T[] => {
29 | return array.sort(() => Math.random() - 0.5);
30 | };
31 |
32 | const shuffledActivities = shuffleArray(activities);
33 | const activity = shuffledActivities[0];
34 |
35 | try {
36 | await user.setActivity(activity);
37 | logger.debug({
38 | guildCount,
39 | memberCount,
40 | message: "Presence updated",
41 | activity,
42 | });
43 | } catch (error) {
44 | logger.error({
45 | guildCount,
46 | memberCount,
47 | message: "Failed to update presence",
48 | error,
49 | });
50 | throw new Error("Failed to update presence");
51 | }
52 | };
53 |
--------------------------------------------------------------------------------
/src/helpers/generateCooldownName.ts:
--------------------------------------------------------------------------------
1 | import { ChatInputCommandInteraction, CommandInteraction } from "discord.js";
2 |
3 | export default async (interaction: CommandInteraction) => {
4 | const { commandName } = interaction;
5 |
6 | if (interaction instanceof ChatInputCommandInteraction) {
7 | const subcommandGroup = interaction.options.getSubcommandGroup();
8 | const subcommand = interaction.options.getSubcommand();
9 |
10 | return subcommandGroup
11 | ? `${commandName}-${subcommandGroup}-${subcommand}`
12 | : `${commandName}-${subcommand}`;
13 | }
14 |
15 | return commandName;
16 | };
17 |
--------------------------------------------------------------------------------
/src/helpers/upsertApiCredentials.ts:
--------------------------------------------------------------------------------
1 | import { Prisma } from "@prisma/client";
2 | import { Guild } from "discord.js";
3 | import prisma from "../handlers/prisma";
4 |
5 | export const upsertApiCredentials = async (
6 | guild: Guild,
7 | apiName: string,
8 | credentials:
9 | | Prisma.NullTypes.JsonNull
10 | | Prisma.InputJsonValue
11 | | Prisma.JsonObject
12 | | Prisma.InputJsonObject
13 | ) => {
14 | await prisma.apiCredentials.upsert({
15 | where: {
16 | guildId_apiName: { guildId: guild.id, apiName },
17 | },
18 | create: {
19 | guildId: guild.id,
20 | apiName,
21 | credentials,
22 | },
23 | update: {
24 | credentials,
25 | },
26 | });
27 | };
28 |
--------------------------------------------------------------------------------
/src/helpers/upsertGuildMember.ts:
--------------------------------------------------------------------------------
1 | import { Guild, User } from "discord.js";
2 | import db from "../handlers/prisma";
3 |
4 | export default async (guild: Guild, user: User) => {
5 | return await db.guildMember.upsert({
6 | where: {
7 | guildId_userId: {
8 | guildId: guild.id,
9 | userId: user.id,
10 | },
11 | },
12 | update: {},
13 | create: {
14 | user: {
15 | connectOrCreate: {
16 | create: {
17 | id: user.id,
18 | },
19 | where: {
20 | id: user.id,
21 | },
22 | },
23 | },
24 | guild: {
25 | connectOrCreate: {
26 | create: {
27 | id: guild.id,
28 | },
29 | where: {
30 | id: guild.id,
31 | },
32 | },
33 | },
34 | },
35 | });
36 | };
37 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import { Client, Collection, GatewayIntentBits } from "discord.js";
2 | import "dotenv/config";
3 | import registerEvents from "./handlers/registerEvents";
4 | import scheduleJobs from "./handlers/scheduleJobs";
5 | import logger from "./utils/logger";
6 |
7 | (async () => {
8 | try {
9 | const client = new Client({
10 | intents: [
11 | GatewayIntentBits.Guilds,
12 | GatewayIntentBits.GuildMembers,
13 | GatewayIntentBits.GuildMessages,
14 | GatewayIntentBits.MessageContent,
15 | ],
16 | });
17 |
18 | client.commands = new Collection();
19 |
20 | await registerEvents(client);
21 | await scheduleJobs(client);
22 |
23 | await client.login(process.env.DISCORD_TOKEN);
24 | } catch (error) {
25 | logger.error("An error occurred in the main process:", error);
26 | }
27 | })();
28 |
--------------------------------------------------------------------------------
/src/interfaces/Command.ts:
--------------------------------------------------------------------------------
1 | import { SlashCommandBuilder } from "discord.js";
2 |
3 | export interface ICommand {
4 | builder: SlashCommandBuilder;
5 | execute: Promise;
6 | }
7 |
--------------------------------------------------------------------------------
/src/interfaces/Event.ts:
--------------------------------------------------------------------------------
1 | import { IEventOptions } from "./EventOptions";
2 |
3 | export interface IEvent {
4 | options: IEventOptions;
5 | execute: (...args: Promise[]) => Promise;
6 | }
7 |
--------------------------------------------------------------------------------
/src/interfaces/EventOptions.ts:
--------------------------------------------------------------------------------
1 | export interface IEventOptions {
2 | type: "on" | "once";
3 | }
4 |
--------------------------------------------------------------------------------
/src/interfaces/Job.ts:
--------------------------------------------------------------------------------
1 | import { Client } from "discord.js";
2 |
3 | export interface IJob {
4 | options: {
5 | schedule: string;
6 | };
7 | execute: (client: Client) => Promise;
8 | }
9 |
--------------------------------------------------------------------------------
/src/jobs/updatePresence.ts:
--------------------------------------------------------------------------------
1 | import { Client } from "discord.js";
2 |
3 | import updatePresence from "../handlers/updatePresence";
4 |
5 | export const options = {
6 | schedule: "*/1 * * * *", // https://crontab.guru/
7 | };
8 |
9 | export const execute = async (client: Client) => {
10 | updatePresence(client);
11 | };
12 |
--------------------------------------------------------------------------------
/src/services/CtrlPanelAPI.ts:
--------------------------------------------------------------------------------
1 | import axios, { AxiosInstance } from "axios";
2 | import { Guild } from "discord.js";
3 | import prisma from "../handlers/prisma";
4 | import { upsertApiCredentials } from "../helpers/upsertApiCredentials";
5 | import Encryption, { EncryptedData } from "../utils/encryption";
6 |
7 | const encryption = new Encryption();
8 |
9 | interface ApiCredentials {
10 | url: EncryptedData;
11 | token: EncryptedData;
12 | [key: string]: EncryptedData | unknown;
13 | }
14 |
15 | export class CtrlPanelAPIError extends Error {
16 | constructor(message: string) {
17 | super(message);
18 | this.name = "CtrlPanelAPIError";
19 | }
20 | }
21 |
22 | class CtrlPanelAPI {
23 | private guild: Guild;
24 | private apiCredentials: ApiCredentials | null;
25 | private api: AxiosInstance;
26 |
27 | constructor(guild: Guild) {
28 | this.guild = guild;
29 | this.apiCredentials = null;
30 | this.api = axios.create();
31 | }
32 |
33 | private async fetchApiCredentials(): Promise {
34 | const apiCredentials = await prisma.apiCredentials.findUnique({
35 | where: {
36 | guildId_apiName: {
37 | guildId: this.guild.id,
38 | apiName: "Ctrlpanel.gg",
39 | },
40 | },
41 | });
42 |
43 | if (!apiCredentials || !apiCredentials.credentials) {
44 | throw new CtrlPanelAPIError(
45 | "API credentials are required for this functionality. Please configure the CtrlPanel.gg API credentials for this guild."
46 | );
47 | }
48 |
49 | this.apiCredentials = apiCredentials.credentials as ApiCredentials;
50 | }
51 |
52 | private async getPlainUrl(): Promise {
53 | if (!this.apiCredentials) {
54 | throw new CtrlPanelAPIError("API credentials not fetched");
55 | }
56 |
57 | const { url } = this.apiCredentials;
58 | return await encryption.decrypt(url);
59 | }
60 |
61 | private async getPlainToken(): Promise {
62 | if (!this.apiCredentials) {
63 | throw new CtrlPanelAPIError("API credentials not fetched");
64 | }
65 |
66 | const { token } = this.apiCredentials;
67 | return await encryption.decrypt(token);
68 | }
69 |
70 | public async generateVoucher(
71 | code: string,
72 | amount: number,
73 | uses: number
74 | ): Promise<{ redeemUrl: string }> {
75 | await this.fetchApiCredentials();
76 |
77 | const plainUrl = await this.getPlainUrl();
78 | const plainToken = await this.getPlainToken();
79 |
80 | this.api.defaults.baseURL = `${plainUrl}/api`;
81 | this.api.defaults.headers.common["Authorization"] = plainToken
82 | ? `Bearer ${plainToken}`
83 | : undefined;
84 |
85 | const shopUrl = `${plainUrl}/store`;
86 |
87 | await this.api.post("vouchers", {
88 | uses,
89 | code,
90 | credits: amount,
91 | memo: `Generated by Discord Bot: ${this.guild.client.user.tag}`,
92 | });
93 |
94 | return { redeemUrl: `${shopUrl}?voucher=${code}` };
95 | }
96 |
97 | public async updateApiCredentials(
98 | scheme: string,
99 | domain: string,
100 | tokenData: string
101 | ): Promise {
102 | const url = await encryption.encrypt(`${scheme}://${domain}`);
103 | const token = await encryption.encrypt(tokenData);
104 |
105 | if (!url || !token) {
106 | throw new Error("URL and token must be set");
107 | }
108 |
109 | const credentials = {
110 | url,
111 | token,
112 | };
113 |
114 | await upsertApiCredentials(this.guild, "Ctrlpanel.gg", credentials);
115 | }
116 | }
117 |
118 | export default CtrlPanelAPI;
119 |
--------------------------------------------------------------------------------
/src/types/common/discord.d.ts:
--------------------------------------------------------------------------------
1 | import { Collection } from "discord.js";
2 | import ICommand from "../../interfaces/Command";
3 |
4 | declare module "discord.js" {
5 | export interface Client extends DJSClient {
6 | commands: Collection;
7 | }
8 | }
9 |
10 | export { Client };
11 |
--------------------------------------------------------------------------------
/src/types/common/environment.d.ts:
--------------------------------------------------------------------------------
1 | import { ColorResolvable, Snowflake } from "discord.js";
2 |
3 | declare global {
4 | namespace NodeJS {
5 | interface ProcessEnv {
6 | MONGO_URL: string;
7 | DISCORD_TOKEN: string;
8 | DISCORD_CLIENT_ID: Snowflake;
9 | DISCORD_GUILD_ID: Snowflake;
10 | DEVELOPMENT_MODE: string;
11 | ENCRYPTION_ALGORITHM: string;
12 | ENCRYPTION_SECRET: string;
13 | EMBED_COLOR_SUCCESS: ColorResolvable;
14 | EMBED_COLOR_WAIT: ColorResolvable;
15 | EMBED_COLOR_ERROR: ColorResolvable;
16 | EMBED_FOOTER_TEXT: string;
17 | EMBED_FOOTER_ICON: string;
18 | LOG_LEVEL: string;
19 | REPUTATION_TIMEOUT: string;
20 | BOT_HOSTER_NAME: string;
21 | BOT_HOSTER_URL: string;
22 | }
23 | }
24 | }
25 |
26 | export {};
27 |
--------------------------------------------------------------------------------
/src/utils/checkPermission.ts:
--------------------------------------------------------------------------------
1 | import { Interaction, PermissionResolvable } from "discord.js";
2 |
3 | export default (interaction: Interaction, permission: PermissionResolvable) => {
4 | if (!interaction.memberPermissions?.has(permission))
5 | throw new Error(`You do not have the required permission: ${permission}`);
6 | };
7 |
--------------------------------------------------------------------------------
/src/utils/deferReply.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ButtonInteraction,
3 | CommandInteraction,
4 | EmbedBuilder,
5 | } from "discord.js";
6 | import sendResponse from "./sendResponse";
7 |
8 | export default async (
9 | interaction: CommandInteraction | ButtonInteraction,
10 | ephemeral: boolean
11 | ) => {
12 | if (!interaction.isRepliable()) {
13 | throw new Error("Failed to reply to your request.");
14 | }
15 |
16 | await interaction.deferReply({ ephemeral });
17 |
18 | await sendResponse(interaction, {
19 | embeds: [
20 | new EmbedBuilder()
21 | .setTimestamp(new Date())
22 | .setTitle("🎉︱Hold on tight!")
23 | .setDescription(
24 | "We're working our magic. This might take a while, so prepare to be amazed! ✨"
25 | ),
26 | ],
27 | });
28 | };
29 |
--------------------------------------------------------------------------------
/src/utils/encryption.ts:
--------------------------------------------------------------------------------
1 | import crypto, { CipherGCMTypes } from "crypto";
2 |
3 | export type EncryptedData = {
4 | iv: string;
5 | content: string;
6 | authTag: string;
7 | };
8 |
9 | class EncryptionError extends Error {
10 | constructor(message: string) {
11 | super(message);
12 | this.name = "EncryptionError";
13 | }
14 | }
15 |
16 | class Encryption {
17 | private encryptionSecret: Buffer;
18 | private encryptionAlgorithm: CipherGCMTypes;
19 | private encryptionKeyLength: number;
20 |
21 | constructor() {
22 | const encryptionSecret = process.env.ENCRYPTION_SECRET;
23 |
24 | if (!encryptionSecret) {
25 | throw new EncryptionError("Encryption secret is required.");
26 | }
27 |
28 | this.encryptionSecret = crypto
29 | .createHash("sha256")
30 | .update(encryptionSecret)
31 | .digest();
32 | this.encryptionAlgorithm = "aes-256-gcm";
33 | this.encryptionKeyLength = 32;
34 | }
35 |
36 | private generateRandomKey(length: number): Buffer {
37 | return crypto.randomBytes(length);
38 | }
39 |
40 | private createCipher(iv: Buffer): crypto.CipherGCM {
41 | const key = this.encryptionSecret.slice(0, this.encryptionKeyLength);
42 | return crypto.createCipheriv(this.encryptionAlgorithm, key, iv);
43 | }
44 |
45 | private createDecipher(iv: Buffer): crypto.DecipherGCM {
46 | const key = this.encryptionSecret.slice(0, this.encryptionKeyLength);
47 | return crypto.createDecipheriv(this.encryptionAlgorithm, key, iv);
48 | }
49 |
50 | private transformData(
51 | data: Buffer,
52 | transform: crypto.CipherGCM | crypto.DecipherGCM
53 | ): Buffer {
54 | return Buffer.concat([transform.update(data), transform.final()]);
55 | }
56 |
57 | public async encrypt(text: string): Promise {
58 | return new Promise((resolve, reject) => {
59 | const iv = this.generateRandomKey(12);
60 | const cipher = this.createCipher(iv);
61 |
62 | let encrypted: Buffer;
63 | let authTag: Buffer;
64 |
65 | try {
66 | encrypted = this.transformData(Buffer.from(text), cipher);
67 | authTag = cipher.getAuthTag();
68 | } catch (error) {
69 | reject(new EncryptionError("Encryption failed."));
70 | return;
71 | }
72 |
73 | resolve({
74 | iv: iv.toString("hex"),
75 | content: encrypted.toString("hex"),
76 | authTag: authTag.toString("hex"),
77 | });
78 | });
79 | }
80 |
81 | public async decrypt(data: EncryptedData): Promise {
82 | return new Promise((resolve, reject) => {
83 | const iv = Buffer.from(data.iv, "hex");
84 | const content = Buffer.from(data.content, "hex");
85 | const authTag = Buffer.from(data.authTag, "hex");
86 |
87 | const decipher = this.createDecipher(iv);
88 | decipher.setAuthTag(authTag);
89 |
90 | let decrypted: Buffer;
91 |
92 | try {
93 | decrypted = this.transformData(content, decipher);
94 | } catch (error) {
95 | reject(new EncryptionError("Decryption failed."));
96 | return;
97 | }
98 |
99 | resolve(decrypted.toString());
100 | });
101 | }
102 | }
103 |
104 | export default Encryption;
105 |
--------------------------------------------------------------------------------
/src/utils/errors.ts:
--------------------------------------------------------------------------------
1 | class CustomError extends Error {
2 | constructor(message: string) {
3 | super(message);
4 | this.name = this.constructor.name;
5 | }
6 | }
7 |
8 | class GuildNotFoundError extends CustomError {
9 | constructor() {
10 | super(
11 | "Guild not found: You are not part of a guild. Join a guild to embark on this adventure."
12 | );
13 | }
14 | }
15 |
16 | class UserNotFoundError extends CustomError {
17 | constructor() {
18 | super(
19 | "User not found: We couldn't retrieve your user information. Please try again or contact support for assistance."
20 | );
21 | }
22 | }
23 |
24 | class ChannelNotFoundError extends CustomError {
25 | constructor() {
26 | super(
27 | "Channel not found: We couldn't retrieve the channel. Please try again or contact support for assistance."
28 | );
29 | }
30 | }
31 |
32 | export { GuildNotFoundError, UserNotFoundError, ChannelNotFoundError };
33 |
--------------------------------------------------------------------------------
/src/utils/logger.ts:
--------------------------------------------------------------------------------
1 | import winston from "winston";
2 | import "winston-daily-rotate-file";
3 |
4 | const { combine, timestamp, json, errors, colorize, align, printf } =
5 | winston.format;
6 |
7 | const logFormat = printf((info) => {
8 | const formattedMessage = info.stack || info.message;
9 | return `[${info.timestamp}] ${info.level}: ${formattedMessage}`;
10 | });
11 |
12 | const logger = winston.createLogger({
13 | level: process.env.LOG_LEVEL || "info",
14 | format: combine(
15 | errors({ stack: true }),
16 | timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
17 | json()
18 | ),
19 | transports: [
20 | new winston.transports.DailyRotateFile({
21 | filename: "logs/combined-%DATE%.log",
22 | datePattern: "YYYY-MM-DD",
23 | maxFiles: "14d",
24 | format: combine(
25 | errors({ stack: true }),
26 | timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
27 | json()
28 | ),
29 | }),
30 | new winston.transports.Console({
31 | format: combine(
32 | errors({ stack: true, trace: true }),
33 | colorize({ all: true }),
34 | timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
35 | align(),
36 | logFormat
37 | ),
38 | }),
39 | ],
40 | });
41 |
42 | export default logger;
43 |
--------------------------------------------------------------------------------
/src/utils/readDirectory.ts:
--------------------------------------------------------------------------------
1 | import fs from "fs/promises";
2 | import path from "path";
3 | import logger from "./logger";
4 |
5 | export default async (filePath: string) => {
6 | const directoryPath = path.join(process.cwd(), "dist", filePath);
7 |
8 | try {
9 | const result = await fs.readdir(directoryPath);
10 | logger.debug({
11 | message: `Checked directory ${filePath}`,
12 | directoryPath,
13 | result,
14 | });
15 | return result;
16 | } catch (error) {
17 | const errorMessage = `Error checking directory ${filePath}: ${error}`;
18 | logger.error({ message: errorMessage, error, directoryPath });
19 | throw new Error(errorMessage);
20 | }
21 | };
22 |
--------------------------------------------------------------------------------
/src/utils/sendResponse.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ButtonInteraction,
3 | CommandInteraction,
4 | InteractionEditReplyOptions,
5 | InteractionReplyOptions,
6 | } from "discord.js";
7 | import logger from "./logger";
8 |
9 | export default async (
10 | interaction: CommandInteraction | ButtonInteraction,
11 | response: InteractionReplyOptions | InteractionEditReplyOptions | string
12 | ) => {
13 | try {
14 | if (interaction instanceof ButtonInteraction) {
15 | await (interaction as ButtonInteraction).reply(
16 | response as InteractionReplyOptions
17 | );
18 | } else {
19 | if (interaction.deferred) {
20 | await (interaction as CommandInteraction).editReply(
21 | response as InteractionEditReplyOptions
22 | );
23 | } else {
24 | await (interaction as CommandInteraction).reply(
25 | response as InteractionReplyOptions
26 | );
27 | }
28 | }
29 | } catch (error) {
30 | logger.error("Error occurred while sending the response:", error);
31 | }
32 | };
33 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es6",
4 | "module": "CommonJS",
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "esModuleInterop": true,
8 | "allowSyntheticDefaultImports": true,
9 | "strict": true,
10 | "forceConsistentCasingInFileNames": true,
11 | "noFallthroughCasesInSwitch": true,
12 | "moduleResolution": "node",
13 | "isolatedModules": true,
14 | "outDir": "./dist",
15 | "rootDir": "./src",
16 | "resolveJsonModule": true,
17 | "typeRoots": ["/types/common", "./node_modules/@types"]
18 | },
19 | "include": ["./src"],
20 | "exclude": ["./node_modules", "./test"]
21 | }
22 |
--------------------------------------------------------------------------------