├── .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 | Support 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 | --------------------------------------------------------------------------------