├── .devcontainer ├── Dockerfile ├── compose.yaml └── devcontainer.json ├── .dockerignore ├── .editorconfig ├── .env.sample ├── .gitattributes ├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE │ ├── bug_report.yaml │ ├── feature_request.yaml │ └── question.yaml ├── PULL_REQUEST_TEMPLATE.md ├── SECURITY.md ├── dependabot.yaml └── workflows │ ├── auto-merge.yaml │ ├── docker.yaml │ └── lint.yaml ├── .gitignore ├── .sonarcloud.properties ├── .vscode ├── extensions.json └── settings.json ├── Dockerfile ├── LICENSE ├── README.md ├── compose.prod.yaml ├── compose.yaml ├── esbuild.config.js ├── eslint.config.js ├── package-lock.json ├── package.json ├── prisma ├── migrations │ ├── 20230729195631_init │ │ └── migration.sql │ ├── 20230802155141_a │ │ └── migration.sql │ ├── 20230819141827_a │ │ └── migration.sql │ ├── 20230822191720_a │ │ └── migration.sql │ ├── 20230825185807_a │ │ └── migration.sql │ ├── 20230826144718_a │ │ └── migration.sql │ ├── 20230827201007_a │ │ └── migration.sql │ ├── 20230828173254_a │ │ └── migration.sql │ ├── 20230916180514_a │ │ └── migration.sql │ ├── 20231128194902_a │ │ └── migration.sql │ ├── 20231212004048_a │ │ └── migration.sql │ ├── 20231212231500_a │ │ └── migration.sql │ ├── 20250104223351_a │ │ └── migration.sql │ ├── 20250303022358_a │ │ └── migration.sql │ ├── 20250831225926_a │ │ └── migration.sql │ └── migration_lock.toml └── schema.prisma ├── src ├── client.ts ├── commands │ ├── Star.ts │ ├── about.ts │ ├── anto.ts │ ├── ask.ts │ ├── askChatbot.ts │ ├── chat.ts │ ├── classroom.ts │ ├── config.ts │ ├── course.ts │ ├── courses.ts │ ├── experience.ts │ ├── faq.ts │ ├── getExperience.ts │ ├── help.ts │ ├── home.ts │ ├── invite.ts │ ├── irregulars.ts │ ├── link.ts │ ├── list.ts │ ├── lottery.ts │ ├── manage.ts │ ├── members.ts │ ├── message.ts │ ├── office.ts │ ├── ping.ts │ ├── profile.ts │ ├── prompt.ts │ ├── purge.ts │ ├── query.ts │ ├── question.ts │ ├── regulars.ts │ ├── reminder.ts │ ├── script.ts │ ├── session.ts │ ├── special.ts │ ├── staff.ts │ ├── statistics.ts │ ├── studentInfo.ts │ ├── ticket.ts │ ├── timeout.ts │ └── vip.ts ├── common │ └── commands │ │ ├── classroom.ts │ │ ├── faq.ts │ │ └── prompt.ts ├── components │ ├── commands.ts │ ├── logs.ts │ ├── pagination.ts │ ├── reminders.ts │ ├── scripts.ts │ ├── tickets.ts │ └── utils.ts ├── configuration │ ├── defaults.ts │ ├── environment.ts │ ├── files.ts │ ├── main.ts │ └── refresh.ts ├── data │ ├── api │ │ ├── Link.ts │ │ └── Question.ts │ └── database │ │ ├── Anto.ts │ │ ├── Bar.ts │ │ ├── Config.ts │ │ ├── Experience.ts │ │ ├── Reminder.ts │ │ └── connection.ts ├── events │ ├── ClientReady.ts │ ├── GuildMemberAdd.ts │ ├── InteractionCreate.ts │ ├── MessageCreate.ts │ ├── MessagePollVoteAdd.ts │ ├── MessagePollVoteRemove.ts │ ├── MessageReactionAdd.ts │ └── MessageUpdate.ts ├── index.ts ├── interactions │ ├── autocomplete.ts │ ├── button.ts │ ├── handlers.ts │ └── utils.ts ├── lib │ ├── schemas │ │ ├── Analytics.ts │ │ ├── Anto.ts │ │ ├── BotConfig.ts │ │ ├── Channel.ts │ │ ├── Chat.ts │ │ ├── Classroom.ts │ │ ├── ClientEvent.ts │ │ ├── CourseInformation.ts │ │ ├── CourseParticipants.ts │ │ ├── CoursePrerequisites.ts │ │ ├── CourseStaff.ts │ │ ├── LevelConfig.ts │ │ ├── Link.ts │ │ ├── Model.ts │ │ ├── PollCategory.ts │ │ ├── PollType.ts │ │ ├── Question.ts │ │ ├── Role.ts │ │ ├── RoleConfig.ts │ │ ├── Staff.ts │ │ └── Ticket.ts │ └── types │ │ ├── Command.ts │ │ ├── PaginationPosition.ts │ │ ├── PartialUser.ts │ │ └── RoleSets.ts ├── logger.ts ├── translations │ ├── about.ts │ ├── commands.ts │ ├── database.ts │ ├── embeds.ts │ ├── emojis.ts │ ├── errors.ts │ ├── experience.ts │ ├── labels.ts │ ├── logs.ts │ ├── pagination.ts │ ├── polls.ts │ ├── special.ts │ ├── tickets.ts │ └── users.ts └── utils │ ├── analytics.ts │ ├── boost.ts │ ├── channels.ts │ ├── chat │ ├── errors.ts │ ├── requests.ts │ └── utils.ts │ ├── commands.ts │ ├── cron │ ├── constants.ts │ └── main.ts │ ├── events.ts │ ├── experience.ts │ ├── guild.ts │ ├── levels.ts │ ├── links.ts │ ├── members.ts │ ├── messages.ts │ ├── options.ts │ ├── permissions.ts │ ├── polls │ ├── actions │ │ ├── lottery.ts │ │ └── special.ts │ ├── constants.ts │ ├── core │ │ ├── lottery.ts │ │ └── special.ts │ ├── main.ts │ └── utils.ts │ ├── process.ts │ ├── regex.ts │ ├── reminders.ts │ ├── roles.ts │ ├── search.ts │ ├── tickets.ts │ └── webhooks.ts └── tsconfig.json /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/devcontainers/typescript-node:22 2 | WORKDIR /app 3 | 4 | USER root 5 | RUN mkdir -p /app/node_modules /app/.husky && chown -R node:node /app/node_modules /app/.husky 6 | USER node 7 | 8 | CMD [ "sleep", "infinity" ] 9 | -------------------------------------------------------------------------------- /.devcontainer/compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | container: 3 | build: 4 | context: . 5 | dockerfile: ./Dockerfile 6 | depends_on: 7 | - database 8 | restart: unless-stopped 9 | user: root 10 | volumes: 11 | - ..:/app 12 | - /app/node_modules 13 | 14 | database: 15 | env_file: 16 | - ../.env 17 | healthcheck: 18 | interval: 30s 19 | retries: 5 20 | start_period: 80s 21 | test: 22 | - "CMD-SHELL" 23 | - "pg_isready -U $$POSTGRES_USER" 24 | timeout: 60s 25 | image: postgres:17 26 | ports: 27 | - "${POSTGRES_PORT}:5432" 28 | restart: unless-stopped 29 | volumes: 30 | - ./db:/var/lib/postgresql/data 31 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "dockerComposeFile": "./compose.yaml", 3 | "forwardPorts": [ 4 | 5432 5 | ], 6 | "name": "finki-discord-bot", 7 | "service": "container", 8 | "shutdownAction": "stopCompose", 9 | "workspaceFolder": "/app", 10 | "postCreateCommand": "npm i && npm run generate" 11 | } 12 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Node modules 2 | node_modules 3 | 4 | # Git 5 | .git 6 | .gitignore 7 | .gitattributes 8 | 9 | # Docker 10 | .dockerignore 11 | Dockerfile* 12 | compose* 13 | 14 | # Output 15 | dist 16 | 17 | # Logs 18 | logs 19 | bot.log 20 | 21 | # Configuration 22 | config 23 | files 24 | sessions 25 | 26 | # Environment 27 | .env* 28 | 29 | # GitHub 30 | .github 31 | *.md 32 | LICENSE 33 | README.md 34 | 35 | # Dev container 36 | .devcontainer 37 | 38 | # ESLint 39 | eslint.config.js 40 | .eslintcache 41 | 42 | # Database 43 | db 44 | 45 | # pgAdmin 46 | pgadmin 47 | 48 | # SonarCloud 49 | .sonarcloud.properties 50 | 51 | # Husky 52 | .husky 53 | 54 | # VS Code 55 | .vscode 56 | 57 | # EditorConfig 58 | .editorconfig 59 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | tab_width = 2 11 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | # Bot 2 | 3 | TOKEN=YOUR_TOKEN 4 | APPLICATION_ID=YOUR_APPLICATION_ID 5 | 6 | # Database 7 | 8 | POSTGRES_DB=db 9 | POSTGRES_USER=db 10 | POSTGRES_PASSWORD=db 11 | POSTGRES_HOST=database 12 | POSTGRES_PORT=5432 13 | 14 | DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:5432/${POSTGRES_DB}" 15 | 16 | # Chatbot 17 | 18 | CHATBOT_URL="http://api:8880" 19 | ANALYTICS_URL="http://analytics:8088" 20 | API_KEY="your_api_key" 21 | 22 | # PGAdmin 23 | 24 | PGADMIN_EMAIL=test@test.com 25 | PGADMIN_PASSWORD=12345 26 | 27 | # Timezone 28 | 29 | TZ=Europe/Berlin 30 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | education, socio-economic status, nationality, personal appearance, race, 10 | religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at {{ email }}. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | [fork]: /fork 4 | [pr]: /compare 5 | [style]: https://github.com/gajus/eslint-plugin-canonical 6 | [code-of-conduct]: CODE_OF_CONDUCT.md 7 | 8 | Hi there! We're thrilled that you'd like to contribute to this project. Your help is essential for keeping it great. 9 | 10 | Please note that this project is released with a [Contributor Code of Conduct][code-of-conduct]. By participating in this project you agree to abide by its terms. 11 | 12 | ## Issues and PRs 13 | 14 | If you have suggestions for how this project could be improved, or want to report a bug, open an issue! We'd love all and any contributions. If you have questions, too, we'd love to hear them. 15 | 16 | We'd also love PRs. If you're thinking of a large PR, we advise opening up an issue first to talk about it, though! Look at the links below if you're not sure how to open a PR. 17 | 18 | ## Submitting a pull request 19 | 20 | 1. [Fork][fork] and clone the repository. 21 | 2. Configure and install the dependencies: `npm i`. 22 | 3. Install the Git pre-commit hooks: `npm run prepare`. 23 | 4. Create a new branch: `git checkout -b `. 24 | 5. Make your changes. 25 | 6. Format your code and check for issues: `npm run format`. 26 | 7. Push to your fork and [submit a pull request][pr]. 27 | 8. Pat your self on the back and wait for your pull request to be reviewed and merged. 28 | 29 | Here are a few things you can do that will increase the likelihood of your pull request being accepted: 30 | 31 | - Follow the [Canonical style guide][style]. Any linting errors should be shown when running `npm run lint`. 32 | - Keep your changes as focused as possible. If there are multiple changes you would like to make that are not dependent upon each other, consider submitting them as separate pull requests. 33 | - Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). 34 | 35 | Work in Progress pull requests are also welcome to get feedback early on, or if there is something blocked you. 36 | 37 | ## Resources 38 | 39 | - [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) 40 | - [Using Pull Requests](https://help.github.com/articles/about-pull-requests/) 41 | - [GitHub Help](https://help.github.com) 42 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yaml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report 3 | labels: 4 | - bug 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to fill out this bug report! 10 | - type: textarea 11 | id: what-happened 12 | attributes: 13 | label: What happened? 14 | description: Describe the issue here. 15 | placeholder: Tell us what you see! 16 | value: "A bug happened!" 17 | validations: 18 | required: true 19 | - type: textarea 20 | id: additional-context 21 | attributes: 22 | label: Other context 23 | description: Any other context, screenshots, or similar that will help us. 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yaml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Request a new feature 3 | labels: 4 | - enhancement 5 | body: 6 | - type: textarea 7 | id: description 8 | attributes: 9 | label: Feature 10 | description: A clear and concise description of what the problem is, or what feature you want to be implemented. 11 | placeholder: I'm always frustrated when..., Discord has recently released..., A good addition would be... 12 | validations: 13 | required: true 14 | - type: textarea 15 | id: solution 16 | attributes: 17 | label: Ideal solution or implementation 18 | description: A clear and concise description of what you want to happen. 19 | validations: 20 | required: true 21 | - type: textarea 22 | id: additional-context 23 | attributes: 24 | label: Other context 25 | description: Any other context, screenshots, or similar that will help us. 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.yaml: -------------------------------------------------------------------------------- 1 | name: Question 2 | description: Ask a question 3 | labels: 4 | - question 5 | body: 6 | - type: textarea 7 | id: issue-description 8 | attributes: 9 | label: What is your question? 10 | description: Please try to be as clear and concise as possible. 11 | validations: 12 | required: true 13 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # **Name** 2 | 3 | 4 | 5 | ## **Description** 6 | 7 | 11 | 12 | ### **Additional** 13 | 14 | 15 | 16 | 21 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # **Reporting Security Issues** 2 | 3 | The project's team and community take security issues. 4 | 5 | We appreciate your efforts to disclose your findings responsibly and will make every effort to acknowledge your contributions. 6 | 7 | To report a security issue, go to the project's issues and create a new issue using the bug report template. 8 | 9 | Read the instructions of this issue template carefully, and if your report could leak data or might expose how to gain access to a restricted area or break the system, please send a mail to [milev.stefan@gmail.com](mailto:milev.stefan@gmail.com). 10 | 11 | We'll endeavour to respond quickly and keep you updated throughout the process. 12 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /.github/workflows/auto-merge.yaml: -------------------------------------------------------------------------------- 1 | name: Dependabot Auto-Merge 2 | 3 | on: pull_request 4 | 5 | permissions: 6 | contents: write 7 | pull-requests: write 8 | 9 | jobs: 10 | dependabot: 11 | name: Dependabot Auto-Merge 12 | runs-on: ubuntu-latest 13 | if: github.actor == 'dependabot[bot]' 14 | 15 | steps: 16 | - name: Dependabot Metadata 17 | id: metadata 18 | uses: dependabot/fetch-metadata@v1 19 | with: 20 | github-token: "${{ secrets.GITHUB_TOKEN }}" 21 | 22 | - name: Enable Auto-Merge 23 | if: steps.metadata.outputs.update-type == 'version-update:semver-patch' || steps.metadata.outputs.update-type == 'version-update:semver-minor' 24 | run: gh pr merge --auto --rebase "$PR_URL" 25 | env: 26 | PR_URL: ${{github.event.pull_request.html_url}} 27 | GH_TOKEN: ${{secrets.GITHUB_TOKEN}} 28 | -------------------------------------------------------------------------------- /.github/workflows/docker.yaml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | - "dev" 8 | paths: 9 | - "src/**" 10 | - "prisma/**" 11 | - "eslint.config.js" 12 | - "package.json" 13 | - "package-lock.json" 14 | - "tsconfig.json" 15 | - ".dockerignore" 16 | - "Dockerfile" 17 | - "esbuild.config.js" 18 | 19 | permissions: 20 | contents: read 21 | packages: write 22 | 23 | jobs: 24 | docker: 25 | name: Docker Image 26 | runs-on: ubuntu-latest 27 | 28 | steps: 29 | - name: Set up QEMU 30 | uses: docker/setup-qemu-action@v3 31 | 32 | - name: Set up Docker Buildx 33 | uses: docker/setup-buildx-action@v3 34 | 35 | - name: Login to GitHub Container Registry 36 | uses: docker/login-action@v3 37 | with: 38 | registry: ghcr.io 39 | username: ${{ github.actor }} 40 | password: ${{ secrets.GITHUB_TOKEN }} 41 | 42 | - name: Build and Push to GHCR 43 | uses: docker/build-push-action@v5 44 | with: 45 | push: true 46 | platforms: linux/amd64,linux/arm64 47 | tags: | 48 | ghcr.io/finki-hub/finki-discord-bot:${{ github.ref == 'refs/heads/main' && 'latest' || 'dev' }} 49 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: Linters 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | paths: 8 | - "src/**" 9 | - "prisma/**" 10 | - "eslint.config.js" 11 | - "package.json" 12 | - "package-lock.json" 13 | - "tsconfig.json" 14 | pull_request: 15 | branches: 16 | - "main" 17 | paths: 18 | - "src/**" 19 | - "prisma/**" 20 | - "eslint.config.js" 21 | - "package.json" 22 | - "package-lock.json" 23 | - "tsconfig.json" 24 | 25 | permissions: 26 | contents: read 27 | 28 | jobs: 29 | eslint: 30 | name: TypeScript & ESLint 31 | runs-on: ubuntu-latest 32 | 33 | steps: 34 | - name: Checkout Repository 35 | uses: actions/checkout@v4 36 | 37 | - name: Set up Node.js 38 | uses: actions/setup-node@v4 39 | with: 40 | node-version: lts/* 41 | 42 | - name: Install Dependencies 43 | run: npm i 44 | 45 | - name: Run ESLint 46 | run: npm run lint 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Node template 2 | # Logs 3 | logs 4 | *.log 5 | npm-debug.log* 6 | yarn-debug.log* 7 | yarn-error.log* 8 | lerna-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Microbundle cache 58 | .rpt2_cache/ 59 | .rts2_cache_cjs/ 60 | .rts2_cache_es/ 61 | .rts2_cache_umd/ 62 | 63 | # Optional REPL history 64 | .node_repl_history 65 | 66 | # Output of 'npm pack' 67 | *.tgz 68 | 69 | # Yarn Integrity file 70 | .yarn-integrity 71 | 72 | # dotenv environment variables file 73 | .env 74 | .env.test 75 | 76 | # parcel-bundler cache (https://parceljs.org/) 77 | .cache 78 | .parcel-cache 79 | 80 | # Next.js build output 81 | .next 82 | out 83 | 84 | # Nuxt.js build / generate output 85 | .nuxt 86 | dist 87 | 88 | # Gatsby files 89 | .cache/ 90 | # Comment in the public line in if your project uses Gatsby and not Next.js 91 | # https://nextjs.org/blog/next-9-1#public-directory-support 92 | # public 93 | 94 | # vuepress build output 95 | .vuepress/dist 96 | 97 | # Serverless directories 98 | .serverless/ 99 | 100 | # FuseBox cache 101 | .fusebox/ 102 | 103 | # DynamoDB Local files 104 | .dynamodb/ 105 | 106 | # TernJS port file 107 | .tern-port 108 | 109 | # Stores VSCode versions used for testing VSCode extensions 110 | .vscode-test 111 | 112 | # yarn v2 113 | .yarn/cache 114 | .yarn/unplugged 115 | .yarn/build-state.yml 116 | .yarn/install-state.gz 117 | .pnp.* 118 | 119 | ### JetBrains template 120 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 121 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 122 | 123 | # User-specific stuff 124 | .idea/**/workspace.xml 125 | .idea/**/tasks.xml 126 | .idea/**/usage.statistics.xml 127 | .idea/**/dictionaries 128 | .idea/**/shelf 129 | 130 | # Generated files 131 | .idea/**/contentModel.xml 132 | 133 | # Sensitive or high-churn files 134 | .idea/**/dataSources/ 135 | .idea/**/dataSources.ids 136 | .idea/**/dataSources.local.xml 137 | .idea/**/sqlDataSources.xml 138 | .idea/**/dynamic.xml 139 | .idea/**/uiDesigner.xml 140 | .idea/**/dbnavigator.xml 141 | 142 | # Gradle 143 | .idea/**/gradle.xml 144 | .idea/**/libraries 145 | 146 | # Gradle and Maven with auto-import 147 | # When using Gradle or Maven with auto-import, you should exclude module files, 148 | # since they will be recreated, and may cause churn. Uncomment if using 149 | # auto-import. 150 | # .idea/artifacts 151 | # .idea/compiler.xml 152 | # .idea/jarRepositories.xml 153 | # .idea/modules.xml 154 | # .idea/*.iml 155 | # .idea/modules 156 | # *.iml 157 | # *.ipr 158 | 159 | # CMake 160 | cmake-build-*/ 161 | 162 | # Mongo Explorer plugin 163 | .idea/**/mongoSettings.xml 164 | 165 | # File-based project format 166 | *.iws 167 | 168 | # IntelliJ 169 | out/ 170 | 171 | # mpeltonen/sbt-idea plugin 172 | .idea_modules/ 173 | 174 | # JIRA plugin 175 | atlassian-ide-plugin.xml 176 | 177 | # Cursive Clojure plugin 178 | .idea/replstate.xml 179 | 180 | # Crashlytics plugin (for Android Studio and IntelliJ) 181 | com_crashlytics_export_strings.xml 182 | crashlytics.properties 183 | crashlytics-build.properties 184 | fabric.properties 185 | 186 | # Editor-based Rest Client 187 | .idea/httpRequests 188 | 189 | # Android studio 3.1+ serialized cache file 190 | .idea/caches/build_file_checksums.ser 191 | 192 | # User defined folders 193 | sessions 194 | files 195 | config 196 | 197 | # Database 198 | db/ 199 | migration/ 200 | pgadmin/ 201 | -------------------------------------------------------------------------------- /.sonarcloud.properties: -------------------------------------------------------------------------------- 1 | sonar.organization=finki-hub 2 | sonar.projectKey=finki-discord-bot 3 | 4 | sonar.sources=. 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "EditorConfig.EditorConfig", 4 | "Prisma.prisma", 5 | "dbaeumer.vscode-eslint", 6 | "eamodio.gitlens", 7 | "docker.docker", 8 | "ms-vscode-remote.remote-containers" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[javascript]": { 3 | "editor.defaultFormatter": "dbaeumer.vscode-eslint", 4 | "editor.tabSize": 2 5 | }, 6 | "[prisma]": { 7 | "editor.defaultFormatter": "Prisma.prisma" 8 | }, 9 | "[typescript]": { 10 | "editor.defaultFormatter": "dbaeumer.vscode-eslint", 11 | "editor.tabSize": 2 12 | }, 13 | "editor.formatOnSave": true, 14 | "editor.insertSpaces": true, 15 | "eslint.format.enable": true, 16 | "eslint.lintTask.enable": true, 17 | "eslint.useFlatConfig": true, 18 | "files.eol": "\n", 19 | "sonarlint.connectedMode.project": { 20 | "connectionId": "finki-hub", 21 | "projectKey": "finki-discord-bot" 22 | }, 23 | "typescript.format.insertSpaceAfterTypeAssertion": true, 24 | "typescript.suggest.autoImports": true, 25 | "typescript.tsdk": "node_modules\\typescript\\lib", 26 | "typescript.updateImportsOnFileMove.enabled": "always" 27 | } 28 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=${BUILDPLATFORM} node:24-alpine AS build 2 | WORKDIR /app 3 | 4 | COPY package.json package-lock.json ./ 5 | RUN npm i --ignore-scripts 6 | 7 | COPY prisma ./prisma 8 | RUN npm run generate 9 | 10 | COPY . ./ 11 | RUN npm run build 12 | 13 | FROM node:24-alpine AS final 14 | WORKDIR /app 15 | 16 | RUN apk add --no-cache openssl 17 | 18 | COPY package.json package-lock.json ./ 19 | RUN npm i --production --ignore-scripts 20 | 21 | COPY --from=build /app/prisma ./prisma 22 | RUN npm run generate && npm cache clean --force 23 | 24 | COPY --from=build /app/dist ./dist 25 | 26 | CMD [ "sh", "-c", "npm run apply && npm run start" ] 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Delemangi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FINKI Discord Bot 2 | 3 | Discord bot for the [`FCSE Students`](https://discord.gg/finki-studenti-810997107376914444) Discord server, powered by [discord.js](https://github.com/discordjs/discord.js) 14. Requires Node.js and PostgreSQL. It is recommended to use the latest LTS versions of both. 4 | 5 | ## Quick Setup (Production) 6 | 7 | If you would like to just run the bot: 8 | 9 | 1. Download [`compose.prod.yaml`](./compose.prod.yaml) 10 | 2. Run `docker compose -f compose.prod.yaml up -d` 11 | 12 | If you wish to avoid Docker, you will have to setup your own PostgreSQL instance and set the `DATABASE_URL` env. variable to point to it. 13 | 14 | This Docker image is available as [ghcr.io/finki-hub/finki-discord-bot](https://github.com/finki-hub/finki-discord-bot/pkgs/container/finki-discord-bot). 15 | 16 | ## Quick Setup (Development) 17 | 18 | 1. Clone the repository: `git clone https://github.com/finki-hub/finki-discord-bot.git` 19 | 2. Install dependencies: `npm i` 20 | 3. Generate the database schema typings: `npm run generate` 21 | 4. Prepare env. variables by copying `env.sample` to `.env` - minimum setup requires `BOT_TOKEN` and `APPLICATION_ID` 22 | 5. Build the project in Docker: `docker compose build` 23 | 6. Run it: `docker compose up -d` 24 | 25 | There is also a dev container available. To use it, just clone the repository, define the env. variables and open the container. Your development environment will be prepared automatically. 26 | 27 | ## Setup Without Docker 28 | 29 | 1. Clone the repository: `git clone https://github.com/finki-hub/finki-discord-bot.git` 30 | 2. Install dependencies: `npm i` 31 | 3. Generate the database schema typings: `npm run generate` 32 | 4. Make sure to have a PostgreSQL instance running 33 | 5. Prepare env. variables by copying `env.sample` to `.env` - minimum setup requires `BOT_TOKEN`, `APPLICATION_ID` and `DATABASE_URL` 34 | 6. Deploy latest database schema: `npm run apply` 35 | 7. Build the project: `npm run build` 36 | 8. Run it: `npm run start:env` or `npm run dev` (for hot reloading) 37 | 38 | ## Configuration 39 | 40 | ### Environment 41 | 42 | The env. variables are stored in `.env.sample`. Only the `BOT_TOKEN` and `APPLICATION_ID` variables are required (for logging in to Discord) and `DATABASE_URL` (for the database connection). 43 | 44 | ### Files 45 | 46 | The data for the informational commands is stored in these files. It is not required to configure them. Here is a list of all files: 47 | 48 | 1. `classrooms.json` - an array of all the classrooms 49 | 2. `courses.json` - an array of the names of all courses 50 | 3. `information.json` - an array of all the course information 51 | 4. `participants.json` - an array of all courses and their number of participants 52 | 5. `prerequisites.json` - an array of course prerequisites 53 | 6. `professors.json` - an array of all courses and their professors and assistants 54 | 7. `roles.json` - roles for the scripts and for the embeds 55 | 8. `sessions.json` - an object of all exam sessions 56 | 9. `staff.json` - an array of the staff 57 | 58 | ### Sessions (Timetables) 59 | 60 | All the session schedule files should be placed in the `sessions` folder. The names of the files should match the respective names in `sessions.json`. 61 | 62 | ## Integration With `finki-chat-bot` 63 | 64 | This project features integration with [`finki-chat-bot`](https://github.com/finki-hub/finki-chat-bot) for enabling the FAQ and links functionality. The Discord bot fetches and mutates data from the chat bot using REST endpoints. If they are deployed in Docker, they should be on the same network to be able to communicate. 65 | 66 | Please set the `CHATBOT_URL` env. variable to the URL of the chat bot. 67 | 68 | ## Frequently Asked Questions 69 | 70 | 1. How to create a database migration? 71 | - Make a change to `prisma.schema` and run `npm run migrate` 72 | 2. How to run all database migrations? 73 | - Run `npm run apply` 74 | 3. Can SQLite be used instead of PostgreSQL? 75 | - Unfortunately, no. Prisma does not allow the database provider to be changed after creating the first migration. 76 | 4. The hot reloading is too slow 77 | - This is a Discord limitation because the bot has to relogin each time 78 | 5. How do I create a Discord bot? 79 | - Refer to 80 | 81 | ## Frequently Encountered Errors 82 | 83 | 1. "The table `public.Config` does not exist in the current database." 84 | - You have not deployed the database migrations. Run `npm run apply` 85 | 2. "Error: Used disallowed intents" 86 | - You must enable all intents in the Discord Developer Portal 87 | 3. "Error \[TokenInvalid]: An invalid token was provided." 88 | - Your bot token is invalid. 89 | 90 | ## License 91 | 92 | This project is licensed under the terms of the MIT license. 93 | -------------------------------------------------------------------------------- /compose.prod.yaml: -------------------------------------------------------------------------------- 1 | name: finki-discord-bot 2 | 3 | services: 4 | bot: 5 | depends_on: 6 | database: 7 | condition: service_healthy 8 | environment: 9 | TOKEN: ${TOKEN} 10 | APPLICATION_ID: ${APPLICATION_ID} 11 | DATABASE_URL: ${DATABASE_URL} 12 | CHATBOT_URL: ${CHATBOT_URL} 13 | ANALYTICS_URL: ${ANALYTICS_URL} 14 | API_KEY: ${API_KEY} 15 | TZ: ${TZ} 16 | image: ghcr.io/finki-hub/finki-discord-bot:latest 17 | networks: 18 | - finki_stack 19 | restart: unless-stopped 20 | volumes: 21 | - ./config:/app/config 22 | - ./sessions:/app/sessions 23 | - ./logs:/app/logs 24 | 25 | database: 26 | environment: 27 | POSTGRES_DB: ${POSTGRES_DB} 28 | POSTGRES_USER: ${POSTGRES_USER} 29 | POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} 30 | healthcheck: 31 | interval: 15s 32 | retries: 3 33 | start_period: 5s 34 | test: 35 | - "CMD-SHELL" 36 | - "pg_isready -U $$POSTGRES_USER" 37 | timeout: 30s 38 | image: postgres:17 39 | networks: 40 | - finki_stack 41 | restart: unless-stopped 42 | volumes: 43 | - ./db:/var/lib/postgresql/data 44 | 45 | networks: 46 | finki_stack: 47 | name: finki_stack 48 | driver: bridge 49 | # Set external to true to use an existing network 50 | # external: true 51 | -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | name: finki-discord-bot-dev 2 | 3 | services: 4 | bot: 5 | build: . 6 | depends_on: 7 | database: 8 | condition: service_healthy 9 | environment: 10 | TOKEN: ${TOKEN} 11 | APPLICATION_ID: ${APPLICATION_ID} 12 | DATABASE_URL: ${DATABASE_URL} 13 | CHATBOT_URL: ${CHATBOT_URL} 14 | ANALYTICS_URL: ${ANALYTICS_URL} 15 | API_KEY: ${API_KEY} 16 | TZ: ${TZ} 17 | image: finki-discord-bot-dev:latest 18 | networks: 19 | - finki_stack_dev 20 | restart: unless-stopped 21 | volumes: 22 | - ./config:/app/config 23 | - ./sessions:/app/sessions 24 | - ./logs:/app/logs 25 | 26 | database: 27 | environment: 28 | POSTGRES_DB: ${POSTGRES_DB} 29 | POSTGRES_USER: ${POSTGRES_USER} 30 | POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} 31 | healthcheck: 32 | interval: 15s 33 | retries: 3 34 | start_period: 5s 35 | test: 36 | - "CMD-SHELL" 37 | - "pg_isready -U $$POSTGRES_USER" 38 | timeout: 30s 39 | image: postgres:17 40 | ports: 41 | - "${POSTGRES_PORT}:5432" 42 | networks: 43 | - finki_stack_dev 44 | restart: unless-stopped 45 | volumes: 46 | - ./db:/var/lib/postgresql/data 47 | 48 | pgadmin: 49 | environment: 50 | PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL} 51 | PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD} 52 | image: dpage/pgadmin4:latest 53 | ports: 54 | - 5050:80 55 | networks: 56 | - finki_stack_dev 57 | restart: unless-stopped 58 | user: "0:0" 59 | volumes: 60 | - ./pgadmin:/var/lib/pgadmin 61 | 62 | networks: 63 | finki_stack_dev: 64 | name: finki_stack_dev 65 | driver: bridge 66 | # Set external to true to use an existing network 67 | # external: true 68 | -------------------------------------------------------------------------------- /esbuild.config.js: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild'; 2 | 3 | await esbuild.build({ 4 | entryPoints: ['src/**/*.ts'], 5 | format: 'esm', 6 | loader: { '.ts': 'ts' }, 7 | minify: true, 8 | outbase: 'src', 9 | outdir: 'dist', 10 | platform: 'node', 11 | sourcemap: false, 12 | target: ['esnext'], 13 | }); 14 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { 2 | base, 3 | node, 4 | perfectionist, 5 | prettier, 6 | typescript, 7 | } from 'eslint-config-imperium'; 8 | 9 | export default [ 10 | { ignores: ['dist/', '.devcontainer/', 'db/', 'pgadmin/', 'logs/'] }, 11 | base, 12 | node, 13 | typescript, 14 | prettier, 15 | perfectionist, 16 | ]; 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "Delemangi", 3 | "bugs": { 4 | "url": "https://github.com/Delemangi/finki-discord-bot/issues" 5 | }, 6 | "dependencies": { 7 | "@prisma/client": "^6.17.1", 8 | "async-lock": "^1.4.1", 9 | "chrono-node": "^2.9.0", 10 | "croner": "^9.1.0", 11 | "discord-api-types": "^0.38.30", 12 | "discord.js": "^14.23.2", 13 | "eventsource-parser": "^3.0.6", 14 | "fuse.js": "^7.1.0", 15 | "prisma": "^6.17.1", 16 | "winston": "^3.18.3", 17 | "zod": "^4.1.12" 18 | }, 19 | "description": "FINKI Discord Bot", 20 | "devDependencies": { 21 | "@types/async-lock": "^1.4.2", 22 | "esbuild": "^0.25.10", 23 | "eslint": "^9.37.0", 24 | "eslint-config-imperium": "^2.7.0", 25 | "rimraf": "^6.0.1", 26 | "tsx": "^4.20.6", 27 | "typescript": "~5.9.3" 28 | }, 29 | "engines": { 30 | "node": "^20 || ^22 || ^24" 31 | }, 32 | "homepage": "https://github.com/Delemangi/finki-discord-bot", 33 | "license": "MIT", 34 | "main": "src/index.ts", 35 | "name": "finki-discord-bot", 36 | "repository": { 37 | "type": "git", 38 | "url": "git+https://github.com/Delemangi/finki-discord-bot.git" 39 | }, 40 | "scripts": { 41 | "apply": "prisma migrate deploy", 42 | "build": "npm run clean && node esbuild.config.js", 43 | "clean": "rimraf dist", 44 | "dev": "node --watch --import=tsx src/index.ts", 45 | "format": "eslint . --cache --fix", 46 | "generate": "prisma generate", 47 | "lint": "tsc --noEmit && eslint . --cache", 48 | "migrate": "prisma migrate dev", 49 | "start": "node dist/index.js", 50 | "start:env": "node --env-file=.env dist/index.js" 51 | }, 52 | "type": "module", 53 | "version": "1.0.0" 54 | } 55 | -------------------------------------------------------------------------------- /prisma/migrations/20230729195631_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Experience" ( 3 | "id" TEXT NOT NULL, 4 | "userId" TEXT NOT NULL, 5 | "experience" BIGINT NOT NULL DEFAULT 0, 6 | "level" INTEGER NOT NULL DEFAULT 0, 7 | "messages" INTEGER NOT NULL DEFAULT 0, 8 | "lastMessage" TIMESTAMP(3), 9 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 10 | "updatedAt" TIMESTAMP(3) NOT NULL, 11 | 12 | CONSTRAINT "Experience_pkey" PRIMARY KEY ("id") 13 | ); 14 | 15 | -- CreateTable 16 | CREATE TABLE "Reminder" ( 17 | "id" TEXT NOT NULL, 18 | "userId" TEXT NOT NULL, 19 | "channelId" TEXT, 20 | "privateMessage" BOOLEAN NOT NULL DEFAULT false, 21 | "description" TEXT NOT NULL DEFAULT 'Reminder', 22 | "timestamp" TIMESTAMP(3) NOT NULL, 23 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 24 | "updatedAt" TIMESTAMP(3) NOT NULL, 25 | 26 | CONSTRAINT "Reminder_pkey" PRIMARY KEY ("id") 27 | ); 28 | 29 | -- CreateTable 30 | CREATE TABLE "Poll" ( 31 | "id" TEXT NOT NULL, 32 | "userId" TEXT NOT NULL, 33 | "channelId" TEXT NOT NULL, 34 | "title" TEXT NOT NULL, 35 | "description" TEXT NOT NULL, 36 | "anonymous" BOOLEAN NOT NULL DEFAULT true, 37 | "multiple" BOOLEAN NOT NULL DEFAULT false, 38 | "open" BOOLEAN NOT NULL DEFAULT false, 39 | "done" BOOLEAN NOT NULL DEFAULT false, 40 | "decision" TEXT, 41 | "threshold" DOUBLE PRECISION NOT NULL DEFAULT 0.5, 42 | "roles" TEXT[] DEFAULT ARRAY[]::TEXT[], 43 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 44 | "updatedAt" TIMESTAMP(3) NOT NULL, 45 | 46 | CONSTRAINT "Poll_pkey" PRIMARY KEY ("id") 47 | ); 48 | 49 | -- CreateTable 50 | CREATE TABLE "PollOption" ( 51 | "id" TEXT NOT NULL, 52 | "name" TEXT NOT NULL, 53 | "votesCount" INTEGER NOT NULL DEFAULT 0, 54 | "pollId" TEXT NOT NULL, 55 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 56 | "updatedAt" TIMESTAMP(3) NOT NULL, 57 | 58 | CONSTRAINT "PollOption_pkey" PRIMARY KEY ("id") 59 | ); 60 | 61 | -- CreateTable 62 | CREATE TABLE "PollVote" ( 63 | "id" TEXT NOT NULL, 64 | "userId" TEXT NOT NULL, 65 | "optionId" TEXT NOT NULL, 66 | "pollId" TEXT NOT NULL, 67 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 68 | "updatedAt" TIMESTAMP(3) NOT NULL, 69 | 70 | CONSTRAINT "PollVote_pkey" PRIMARY KEY ("id") 71 | ); 72 | 73 | -- CreateTable 74 | CREATE TABLE "VipPoll" ( 75 | "id" TEXT NOT NULL, 76 | "userId" TEXT NOT NULL, 77 | "type" TEXT NOT NULL DEFAULT 'add', 78 | "pollId" TEXT NOT NULL, 79 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 80 | "updatedAt" TIMESTAMP(3) NOT NULL, 81 | 82 | CONSTRAINT "VipPoll_pkey" PRIMARY KEY ("id") 83 | ); 84 | 85 | -- CreateIndex 86 | CREATE UNIQUE INDEX "Experience_userId_key" ON "Experience"("userId"); 87 | 88 | -- CreateIndex 89 | CREATE UNIQUE INDEX "VipPoll_pollId_key" ON "VipPoll"("pollId"); 90 | 91 | -- CreateIndex 92 | CREATE UNIQUE INDEX "VipPoll_userId_type_key" ON "VipPoll"("userId", "type"); 93 | 94 | -- AddForeignKey 95 | ALTER TABLE "PollOption" ADD CONSTRAINT "PollOption_pollId_fkey" FOREIGN KEY ("pollId") REFERENCES "Poll"("id") ON DELETE CASCADE ON UPDATE CASCADE; 96 | 97 | -- AddForeignKey 98 | ALTER TABLE "PollVote" ADD CONSTRAINT "PollVote_optionId_fkey" FOREIGN KEY ("optionId") REFERENCES "PollOption"("id") ON DELETE CASCADE ON UPDATE CASCADE; 99 | 100 | -- AddForeignKey 101 | ALTER TABLE "PollVote" ADD CONSTRAINT "PollVote_pollId_fkey" FOREIGN KEY ("pollId") REFERENCES "Poll"("id") ON DELETE CASCADE ON UPDATE CASCADE; 102 | 103 | -- AddForeignKey 104 | ALTER TABLE "VipPoll" ADD CONSTRAINT "VipPoll_pollId_fkey" FOREIGN KEY ("pollId") REFERENCES "Poll"("id") ON DELETE CASCADE ON UPDATE CASCADE; 105 | -------------------------------------------------------------------------------- /prisma/migrations/20230802155141_a/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Config" ( 3 | "name" TEXT NOT NULL, 4 | "value" JSONB NOT NULL, 5 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 6 | "updatedAt" TIMESTAMP(3) NOT NULL, 7 | 8 | CONSTRAINT "Config_pkey" PRIMARY KEY ("name") 9 | ); 10 | -------------------------------------------------------------------------------- /prisma/migrations/20230819141827_a/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Question" ( 3 | "id" TEXT NOT NULL, 4 | "name" TEXT NOT NULL, 5 | "content" TEXT NOT NULL, 6 | "userId" TEXT, 7 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 8 | "updatedAt" TIMESTAMP(3) NOT NULL, 9 | 10 | CONSTRAINT "Question_pkey" PRIMARY KEY ("id") 11 | ); 12 | 13 | -- CreateTable 14 | CREATE TABLE "QuestionLink" ( 15 | "id" TEXT NOT NULL, 16 | "name" TEXT NOT NULL, 17 | "url" TEXT NOT NULL, 18 | "questionId" TEXT NOT NULL, 19 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 20 | "updatedAt" TIMESTAMP(3) NOT NULL, 21 | 22 | CONSTRAINT "QuestionLink_pkey" PRIMARY KEY ("id") 23 | ); 24 | 25 | -- CreateIndex 26 | CREATE UNIQUE INDEX "Question_name_key" ON "Question"("name"); 27 | 28 | -- AddForeignKey 29 | ALTER TABLE "QuestionLink" ADD CONSTRAINT "QuestionLink_questionId_fkey" FOREIGN KEY ("questionId") REFERENCES "Question"("id") ON DELETE CASCADE ON UPDATE CASCADE; 30 | -------------------------------------------------------------------------------- /prisma/migrations/20230822191720_a/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Link" ( 3 | "id" TEXT NOT NULL, 4 | "name" TEXT NOT NULL, 5 | "description" TEXT, 6 | "url" TEXT NOT NULL, 7 | "userId" TEXT, 8 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 9 | "updatedAt" TIMESTAMP(3) NOT NULL, 10 | 11 | CONSTRAINT "Link_pkey" PRIMARY KEY ("id") 12 | ); 13 | 14 | -- CreateIndex 15 | CREATE UNIQUE INDEX "Link_name_key" ON "Link"("name"); 16 | 17 | -- CreateIndex 18 | CREATE UNIQUE INDEX "Link_url_key" ON "Link"("url"); 19 | -------------------------------------------------------------------------------- /prisma/migrations/20230825185807_a/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Anto" ( 3 | "id" TEXT NOT NULL, 4 | "quote" TEXT NOT NULL, 5 | "userId" TEXT, 6 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 7 | "updatedAt" TIMESTAMP(3) NOT NULL, 8 | 9 | CONSTRAINT "Anto_pkey" PRIMARY KEY ("id") 10 | ); 11 | 12 | -- CreateIndex 13 | CREATE UNIQUE INDEX "Anto_quote_key" ON "Anto"("quote"); 14 | -------------------------------------------------------------------------------- /prisma/migrations/20230826144718_a/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Rule" ( 3 | "id" TEXT NOT NULL, 4 | "rule" TEXT NOT NULL, 5 | "userId" TEXT, 6 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 7 | "updatedAt" TIMESTAMP(3) NOT NULL, 8 | 9 | CONSTRAINT "Rule_pkey" PRIMARY KEY ("id") 10 | ); 11 | 12 | -- CreateIndex 13 | CREATE UNIQUE INDEX "Rule_rule_key" ON "Rule"("rule"); 14 | -------------------------------------------------------------------------------- /prisma/migrations/20230827201007_a/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | CREATE TYPE "InfoMessageType" AS ENUM ('TEXT', 'IMAGE'); 3 | 4 | -- CreateTable 5 | CREATE TABLE "InfoMessage" ( 6 | "id" TEXT NOT NULL, 7 | "index" INTEGER NOT NULL, 8 | "type" "InfoMessageType" NOT NULL, 9 | "content" TEXT NOT NULL, 10 | "userId" TEXT, 11 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 12 | "updatedAt" TIMESTAMP(3) NOT NULL, 13 | 14 | CONSTRAINT "InfoMessage_pkey" PRIMARY KEY ("id") 15 | ); 16 | 17 | -- CreateIndex 18 | CREATE UNIQUE INDEX "InfoMessage_index_key" ON "InfoMessage"("index"); 19 | -------------------------------------------------------------------------------- /prisma/migrations/20230828173254_a/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Company" ( 3 | "id" TEXT NOT NULL, 4 | "name" TEXT NOT NULL, 5 | "userId" TEXT, 6 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 7 | "updatedAt" TIMESTAMP(3) NOT NULL, 8 | 9 | CONSTRAINT "Company_pkey" PRIMARY KEY ("id") 10 | ); 11 | 12 | -- CreateIndex 13 | CREATE UNIQUE INDEX "Company_name_key" ON "Company"("name"); 14 | -------------------------------------------------------------------------------- /prisma/migrations/20230916180514_a/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "VipBan" ( 3 | "id" TEXT NOT NULL, 4 | "userId" TEXT NOT NULL, 5 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 6 | "updatedAt" TIMESTAMP(3) NOT NULL, 7 | 8 | CONSTRAINT "VipBan_pkey" PRIMARY KEY ("id") 9 | ); 10 | 11 | -- CreateIndex 12 | CREATE UNIQUE INDEX "VipBan_userId_key" ON "VipBan"("userId"); 13 | -------------------------------------------------------------------------------- /prisma/migrations/20231128194902_a/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the `VipPoll` table. If the table is not empty, all the data it contains will be lost. 5 | 6 | */ 7 | -- DropForeignKey 8 | ALTER TABLE "VipPoll" DROP CONSTRAINT "VipPoll_pollId_fkey"; 9 | 10 | -- DropTable 11 | DROP TABLE "VipPoll"; 12 | 13 | -- CreateTable 14 | CREATE TABLE "SpecialPoll" ( 15 | "id" TEXT NOT NULL, 16 | "userId" TEXT NOT NULL, 17 | "type" TEXT NOT NULL DEFAULT 'add', 18 | "pollId" TEXT NOT NULL, 19 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 20 | "updatedAt" TIMESTAMP(3) NOT NULL, 21 | 22 | CONSTRAINT "SpecialPoll_pkey" PRIMARY KEY ("id") 23 | ); 24 | 25 | -- CreateIndex 26 | CREATE UNIQUE INDEX "SpecialPoll_pollId_key" ON "SpecialPoll"("pollId"); 27 | 28 | -- CreateIndex 29 | CREATE UNIQUE INDEX "SpecialPoll_userId_type_key" ON "SpecialPoll"("userId", "type"); 30 | 31 | -- AddForeignKey 32 | ALTER TABLE "SpecialPoll" ADD CONSTRAINT "SpecialPoll_pollId_fkey" FOREIGN KEY ("pollId") REFERENCES "Poll"("id") ON DELETE CASCADE ON UPDATE CASCADE; 33 | -------------------------------------------------------------------------------- /prisma/migrations/20231212004048_a/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "SpecialPoll" ADD COLUMN "timestamp" TIMESTAMP(3), 3 | ALTER COLUMN "type" SET DEFAULT 'vipAdd'; 4 | -------------------------------------------------------------------------------- /prisma/migrations/20231212231500_a/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the `VipBan` table. If the table is not empty, all the data it contains will be lost. 5 | 6 | */ 7 | -- DropTable 8 | DROP TABLE "VipBan"; 9 | 10 | -- CreateTable 11 | CREATE TABLE "Bar" ( 12 | "id" TEXT NOT NULL, 13 | "userId" TEXT NOT NULL, 14 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 15 | "updatedAt" TIMESTAMP(3) NOT NULL, 16 | 17 | CONSTRAINT "Bar_pkey" PRIMARY KEY ("id") 18 | ); 19 | 20 | -- CreateIndex 21 | CREATE UNIQUE INDEX "Bar_userId_key" ON "Bar"("userId"); 22 | -------------------------------------------------------------------------------- /prisma/migrations/20250104223351_a/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the `Poll` table. If the table is not empty, all the data it contains will be lost. 5 | - You are about to drop the `PollOption` table. If the table is not empty, all the data it contains will be lost. 6 | - You are about to drop the `PollVote` table. If the table is not empty, all the data it contains will be lost. 7 | - You are about to drop the `SpecialPoll` table. If the table is not empty, all the data it contains will be lost. 8 | 9 | */ 10 | -- DropForeignKey 11 | ALTER TABLE "PollOption" DROP CONSTRAINT "PollOption_pollId_fkey"; 12 | 13 | -- DropForeignKey 14 | ALTER TABLE "PollVote" DROP CONSTRAINT "PollVote_optionId_fkey"; 15 | 16 | -- DropForeignKey 17 | ALTER TABLE "PollVote" DROP CONSTRAINT "PollVote_pollId_fkey"; 18 | 19 | -- DropForeignKey 20 | ALTER TABLE "SpecialPoll" DROP CONSTRAINT "SpecialPoll_pollId_fkey"; 21 | 22 | -- DropTable 23 | DROP TABLE "Poll"; 24 | 25 | -- DropTable 26 | DROP TABLE "PollOption"; 27 | 28 | -- DropTable 29 | DROP TABLE "PollVote"; 30 | 31 | -- DropTable 32 | DROP TABLE "SpecialPoll"; 33 | -------------------------------------------------------------------------------- /prisma/migrations/20250303022358_a/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the `Link` table. If the table is not empty, all the data it contains will be lost. 5 | - You are about to drop the `Question` table. If the table is not empty, all the data it contains will be lost. 6 | - You are about to drop the `QuestionLink` table. If the table is not empty, all the data it contains will be lost. 7 | 8 | */ 9 | -- DropForeignKey 10 | ALTER TABLE "QuestionLink" DROP CONSTRAINT "QuestionLink_questionId_fkey"; 11 | 12 | -- DropTable 13 | DROP TABLE "Link"; 14 | 15 | -- DropTable 16 | DROP TABLE "Question"; 17 | 18 | -- DropTable 19 | DROP TABLE "QuestionLink"; 20 | -------------------------------------------------------------------------------- /prisma/migrations/20250831225926_a/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the `Company` table. If the table is not empty, all the data it contains will be lost. 5 | - You are about to drop the `InfoMessage` table. If the table is not empty, all the data it contains will be lost. 6 | - You are about to drop the `Rule` table. If the table is not empty, all the data it contains will be lost. 7 | 8 | */ 9 | -- DropTable 10 | DROP TABLE "public"."Company"; 11 | 12 | -- DropTable 13 | DROP TABLE "public"."InfoMessage"; 14 | 15 | -- DropTable 16 | DROP TABLE "public"."Rule"; 17 | 18 | -- DropEnum 19 | DROP TYPE "public"."InfoMessageType"; 20 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (e.g., Git) 3 | provider = "postgresql" 4 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "postgresql" 7 | url = env("DATABASE_URL") 8 | } 9 | 10 | model Config { 11 | name String @id 12 | value Json 13 | 14 | createdAt DateTime @default(now()) 15 | updatedAt DateTime @updatedAt 16 | } 17 | 18 | model Anto { 19 | id String @id @default(uuid()) 20 | 21 | quote String @unique 22 | userId String? 23 | 24 | createdAt DateTime @default(now()) 25 | updatedAt DateTime @updatedAt 26 | } 27 | 28 | model Experience { 29 | id String @id @default(uuid()) 30 | 31 | userId String @unique 32 | experience BigInt @default(0) 33 | level Int @default(0) 34 | messages Int @default(0) 35 | lastMessage DateTime? 36 | 37 | createdAt DateTime @default(now()) 38 | updatedAt DateTime @updatedAt 39 | } 40 | 41 | model Reminder { 42 | id String @id @default(uuid()) 43 | 44 | userId String 45 | channelId String? 46 | privateMessage Boolean @default(false) 47 | description String @default("Reminder") 48 | timestamp DateTime 49 | 50 | createdAt DateTime @default(now()) 51 | updatedAt DateTime @updatedAt 52 | } 53 | 54 | model Bar { 55 | id String @id @default(uuid()) 56 | 57 | userId String @unique 58 | 59 | createdAt DateTime @default(now()) 60 | updatedAt DateTime @updatedAt 61 | } 62 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import { GatewayIntentBits } from 'discord-api-types/v10'; 2 | import { Client, Partials } from 'discord.js'; 3 | 4 | const intents = [ 5 | GatewayIntentBits.Guilds, 6 | GatewayIntentBits.GuildMessages, 7 | GatewayIntentBits.GuildMembers, 8 | GatewayIntentBits.GuildMessageReactions, 9 | GatewayIntentBits.GuildExpressions, 10 | GatewayIntentBits.GuildMessagePolls, 11 | ]; 12 | 13 | const partials = [Partials.Message, Partials.Poll, Partials.PollAnswer]; 14 | 15 | const presence = { 16 | activities: [ 17 | { 18 | name: 'World Domination', 19 | }, 20 | ], 21 | }; 22 | 23 | export const client = new Client({ 24 | intents, 25 | partials, 26 | presence, 27 | }); 28 | -------------------------------------------------------------------------------- /src/commands/Star.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApplicationCommandType, 3 | ContextMenuCommandBuilder, 4 | InteractionContextType, 5 | type UserContextMenuCommandInteraction, 6 | } from 'discord.js'; 7 | 8 | import { truncateString } from '../components/utils.js'; 9 | import { getChannelsProperty } from '../configuration/main.js'; 10 | import { Channel } from '../lib/schemas/Channel.js'; 11 | import { 12 | commandErrors, 13 | commandResponseFunctions, 14 | } from '../translations/commands.js'; 15 | import { labels } from '../translations/labels.js'; 16 | import { getMemberFromGuild } from '../utils/guild.js'; 17 | import { getOrCreateWebhookByChannelId } from '../utils/webhooks.js'; 18 | 19 | const name = 'Star'; 20 | 21 | export const data = new ContextMenuCommandBuilder() 22 | .setName(name) 23 | .setType(ApplicationCommandType.Message) 24 | .setContexts(InteractionContextType.Guild); 25 | 26 | export const execute = async ( 27 | interaction: UserContextMenuCommandInteraction, 28 | ) => { 29 | const webhooksChannel = getChannelsProperty(Channel.Starboard); 30 | 31 | if (webhooksChannel === undefined) { 32 | await interaction.editReply(commandErrors.invalidChannel); 33 | 34 | return; 35 | } 36 | 37 | const webhook = await getOrCreateWebhookByChannelId(webhooksChannel); 38 | const message = await interaction.channel?.messages.fetch( 39 | interaction.targetId, 40 | ); 41 | const member = await getMemberFromGuild( 42 | message?.author.id ?? '', 43 | interaction.guild, 44 | ); 45 | 46 | if (member === null) { 47 | await interaction.editReply(commandErrors.memberNotFound); 48 | 49 | return; 50 | } 51 | 52 | await webhook?.send({ 53 | allowedMentions: { 54 | parse: [], 55 | }, 56 | avatarURL: member.displayAvatarURL(), 57 | content: `${truncateString(message?.content, 1_500)}\n\n${labels.link}: ${message?.url}`, 58 | files: message?.attachments.map((attachment) => attachment.url) ?? [], 59 | username: member.displayName, 60 | }); 61 | 62 | await interaction.editReply( 63 | commandResponseFunctions.messageStarred(message?.url ?? labels.unknown), 64 | ); 65 | }; 66 | -------------------------------------------------------------------------------- /src/commands/about.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ChatInputCommandInteraction, 3 | SlashCommandBuilder, 4 | } from 'discord.js'; 5 | 6 | import { getAboutEmbed } from '../components/commands.js'; 7 | import { commandDescriptions } from '../translations/commands.js'; 8 | 9 | const name = 'about'; 10 | 11 | export const data = new SlashCommandBuilder() 12 | .setName(name) 13 | .setDescription(commandDescriptions[name]); 14 | 15 | export const execute = async (interaction: ChatInputCommandInteraction) => { 16 | const embed = getAboutEmbed(); 17 | await interaction.editReply({ 18 | embeds: [embed], 19 | }); 20 | }; 21 | -------------------------------------------------------------------------------- /src/commands/anto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ChatInputCommandInteraction, 3 | SlashCommandBuilder, 4 | } from 'discord.js'; 5 | 6 | import { getRandomAnto } from '../data/database/Anto.js'; 7 | import { 8 | commandDescriptions, 9 | commandErrors, 10 | } from '../translations/commands.js'; 11 | 12 | const name = 'anto'; 13 | 14 | export const data = new SlashCommandBuilder() 15 | .setName(name) 16 | .setDescription(commandDescriptions[name]); 17 | 18 | export const execute = async (interaction: ChatInputCommandInteraction) => { 19 | const anto = await getRandomAnto(); 20 | await interaction.editReply(anto?.quote ?? commandErrors.noAnto); 21 | }; 22 | -------------------------------------------------------------------------------- /src/commands/ask.ts: -------------------------------------------------------------------------------- 1 | import { getCommonCommand } from '../common/commands/faq.js'; 2 | 3 | const { data, execute } = getCommonCommand('ask'); 4 | 5 | export { data, execute }; 6 | -------------------------------------------------------------------------------- /src/commands/askChatbot.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApplicationCommandType, 3 | ContextMenuCommandBuilder, 4 | type MessageContextMenuCommandInteraction, 5 | } from 'discord.js'; 6 | 7 | import { getConfigProperty } from '../configuration/main.js'; 8 | import { SendPromptOptionsSchema } from '../lib/schemas/Chat.js'; 9 | import { commandErrors } from '../translations/commands.js'; 10 | import { sendPrompt } from '../utils/chat/requests.js'; 11 | import { safeStreamReplyToInteraction } from '../utils/messages.js'; 12 | 13 | const name = 'Ask Chatbot'; 14 | 15 | export const data = new ContextMenuCommandBuilder() 16 | .setName(name) 17 | .setType(ApplicationCommandType.Message); 18 | 19 | export const execute = async ( 20 | interaction: MessageContextMenuCommandInteraction, 21 | ) => { 22 | const message = interaction.targetMessage; 23 | 24 | const models = getConfigProperty('models'); 25 | 26 | const options = SendPromptOptionsSchema.parse({ 27 | embeddingsModel: models.embeddings, 28 | inferenceModel: models.inference, 29 | prompt: message.content, 30 | }); 31 | 32 | try { 33 | await safeStreamReplyToInteraction(interaction, async (onChunk) => { 34 | await sendPrompt(options, async (chunk) => { 35 | await onChunk(chunk); 36 | }); 37 | }); 38 | } catch (error) { 39 | const isLLMUnavailable = 40 | error instanceof Error && error.message === 'LLM_UNAVAILABLE'; 41 | 42 | const errorMessage = isLLMUnavailable 43 | ? commandErrors.llmUnavailable 44 | : commandErrors.unknownChatError; 45 | 46 | await (interaction.deferred || interaction.replied 47 | ? interaction.editReply(errorMessage) 48 | : interaction.reply({ 49 | content: errorMessage, 50 | ephemeral: true, 51 | })); 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /src/commands/classroom.ts: -------------------------------------------------------------------------------- 1 | import { getCommonCommand } from '../common/commands/classroom.js'; 2 | 3 | const { data, execute } = getCommonCommand('classroom'); 4 | 5 | export { data, execute }; 6 | -------------------------------------------------------------------------------- /src/commands/faq.ts: -------------------------------------------------------------------------------- 1 | import { getCommonCommand } from '../common/commands/faq.js'; 2 | 3 | const { data, execute } = getCommonCommand('faq'); 4 | 5 | export { data, execute }; 6 | -------------------------------------------------------------------------------- /src/commands/getExperience.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApplicationCommandType, 3 | ContextMenuCommandBuilder, 4 | type MessageContextMenuCommandInteraction, 5 | PermissionFlagsBits, 6 | } from 'discord.js'; 7 | 8 | import { getExperienceFromMessage } from '../utils/experience.js'; 9 | 10 | const name = 'Get Experience'; 11 | 12 | export const data = new ContextMenuCommandBuilder() 13 | .setName(name) 14 | .setType(ApplicationCommandType.Message) 15 | .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages); 16 | 17 | export const execute = async ( 18 | interaction: MessageContextMenuCommandInteraction, 19 | ) => { 20 | const experience = await getExperienceFromMessage(interaction.targetMessage); 21 | 22 | await interaction.editReply( 23 | `${interaction.targetMessage.url}: ${experience.toString()}`, 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /src/commands/help.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ChatInputCommandInteraction, 3 | ComponentType, 4 | MessageFlags, 5 | SlashCommandBuilder, 6 | } from 'discord.js'; 7 | 8 | import { client } from '../client.js'; 9 | import { getHelpEmbed } from '../components/commands.js'; 10 | import { getPaginationComponents } from '../components/pagination.js'; 11 | import { getIntervalsProperty } from '../configuration/main.js'; 12 | import { logger } from '../logger.js'; 13 | import { 14 | commandDescriptions, 15 | commandErrors, 16 | } from '../translations/commands.js'; 17 | import { logErrorFunctions } from '../translations/logs.js'; 18 | import { deleteResponse } from '../utils/channels.js'; 19 | import { getGuild, getMemberFromGuild } from '../utils/guild.js'; 20 | import { getCommandsWithPermission } from '../utils/permissions.js'; 21 | 22 | const name = 'help'; 23 | 24 | export const data = new SlashCommandBuilder() 25 | .setName(name) 26 | .setDescription(commandDescriptions[name]); 27 | 28 | export const execute = async (interaction: ChatInputCommandInteraction) => { 29 | const guild = await getGuild(interaction); 30 | 31 | if (guild === null) { 32 | await interaction.editReply(commandErrors.guildFetchFailed); 33 | 34 | return; 35 | } 36 | 37 | const member = await getMemberFromGuild( 38 | interaction.user.id, 39 | interaction.guild, 40 | ); 41 | 42 | if (member === null) { 43 | await interaction.editReply(commandErrors.commandNoPermission); 44 | return; 45 | } 46 | 47 | await client.application?.commands.fetch(); 48 | 49 | const commands = getCommandsWithPermission(member); 50 | const commandsPerPage = 8; 51 | const pages = Math.ceil(commands.length / commandsPerPage); 52 | const embed = getHelpEmbed(commands, 0, commandsPerPage); 53 | const components = [ 54 | pages === 0 || pages === 1 55 | ? getPaginationComponents('help') 56 | : getPaginationComponents('help', 'start'), 57 | ]; 58 | const message = await interaction.editReply({ 59 | components, 60 | embeds: [embed], 61 | }); 62 | const buttonIdle = getIntervalsProperty('buttonIdle'); 63 | const collector = message.createMessageComponentCollector({ 64 | componentType: ComponentType.Button, 65 | idle: buttonIdle, 66 | }); 67 | 68 | collector.on('collect', async (buttonInteraction) => { 69 | if ( 70 | buttonInteraction.user.id !== 71 | buttonInteraction.message.interactionMetadata?.user.id 72 | ) { 73 | const mess = await buttonInteraction.reply({ 74 | content: commandErrors.buttonNoPermission, 75 | flags: MessageFlags.Ephemeral, 76 | }); 77 | void deleteResponse(mess); 78 | 79 | return; 80 | } 81 | 82 | const id = buttonInteraction.customId.split(':')[1]; 83 | 84 | if (id === undefined) { 85 | return; 86 | } 87 | 88 | let buttons; 89 | let page = 90 | Number( 91 | buttonInteraction.message.embeds[0]?.footer?.text.match(/\d+/gu)?.[0], 92 | ) - 1; 93 | 94 | switch (id) { 95 | case 'first': 96 | page = 0; 97 | break; 98 | 99 | case 'last': 100 | page = pages - 1; 101 | break; 102 | 103 | case 'next': 104 | page++; 105 | break; 106 | 107 | case 'previous': 108 | page--; 109 | break; 110 | 111 | default: 112 | page = 0; 113 | break; 114 | } 115 | 116 | if (page === 0 && (pages === 0 || pages === 1)) { 117 | buttons = getPaginationComponents('help'); 118 | } else if (page === 0) { 119 | buttons = getPaginationComponents('help', 'start'); 120 | } else if (page === pages - 1) { 121 | buttons = getPaginationComponents('help', 'end'); 122 | } else { 123 | buttons = getPaginationComponents('help', 'middle'); 124 | } 125 | 126 | const nextEmbed = getHelpEmbed(commands, page, commandsPerPage); 127 | 128 | try { 129 | await buttonInteraction.update({ 130 | components: [buttons], 131 | embeds: [nextEmbed], 132 | }); 133 | } catch (error) { 134 | logger.error( 135 | logErrorFunctions.interactionUpdateError( 136 | buttonInteraction.customId, 137 | error, 138 | ), 139 | ); 140 | } 141 | }); 142 | 143 | collector.on('end', async () => { 144 | try { 145 | await interaction.editReply({ 146 | components: [getPaginationComponents('help')], 147 | }); 148 | } catch (error) { 149 | logger.error(logErrorFunctions.collectorEndError(name, error)); 150 | } 151 | }); 152 | }; 153 | -------------------------------------------------------------------------------- /src/commands/home.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ChatInputCommandInteraction, 3 | SlashCommandBuilder, 4 | } from 'discord.js'; 5 | 6 | import { commandDescriptions } from '../translations/commands.js'; 7 | 8 | const name = 'home'; 9 | 10 | export const data = new SlashCommandBuilder() 11 | .setName(name) 12 | .setDescription(commandDescriptions[name]); 13 | 14 | export const execute = async (interaction: ChatInputCommandInteraction) => { 15 | await interaction.editReply('https://github.com/finki-hub/finki-discord-bot'); 16 | }; 17 | -------------------------------------------------------------------------------- /src/commands/invite.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ChatInputCommandInteraction, 3 | SlashCommandBuilder, 4 | } from 'discord.js'; 5 | 6 | import { 7 | commandDescriptions, 8 | commandErrors, 9 | } from '../translations/commands.js'; 10 | import { getGuild } from '../utils/guild.js'; 11 | 12 | const name = 'invite'; 13 | 14 | export const data = new SlashCommandBuilder() 15 | .setName(name) 16 | .setDescription(commandDescriptions[name]); 17 | 18 | export const execute = async (interaction: ChatInputCommandInteraction) => { 19 | const guild = await getGuild(interaction); 20 | 21 | if (guild === null) { 22 | await interaction.editReply(commandErrors.guildFetchFailed); 23 | 24 | return; 25 | } 26 | 27 | const vanityCode = guild.vanityURLCode; 28 | 29 | if (vanityCode === null) { 30 | const invite = await guild.rulesChannel?.createInvite({ 31 | maxAge: 0, 32 | maxUses: 0, 33 | unique: true, 34 | }); 35 | 36 | if (invite === undefined) { 37 | await interaction.editReply(commandErrors.inviteCreationFailed); 38 | 39 | return; 40 | } 41 | 42 | await interaction.editReply(`https://discord.gg/${invite.code}`); 43 | 44 | return; 45 | } 46 | 47 | await interaction.editReply(`https://discord.gg/${vanityCode}`); 48 | }; 49 | -------------------------------------------------------------------------------- /src/commands/link.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ChatInputCommandInteraction, 3 | SlashCommandBuilder, 4 | } from 'discord.js'; 5 | 6 | import { 7 | commandDescriptions, 8 | commandErrors, 9 | commandResponseFunctions, 10 | } from '../translations/commands.js'; 11 | import { getNormalizedUrl } from '../utils/links.js'; 12 | import { getClosestLink } from '../utils/search.js'; 13 | 14 | const name = 'link'; 15 | 16 | export const data = new SlashCommandBuilder() 17 | .setName(name) 18 | .setDescription(commandDescriptions[name]) 19 | .addStringOption((option) => 20 | option 21 | .setName('link') 22 | .setDescription('Линк') 23 | .setRequired(true) 24 | .setAutocomplete(true), 25 | ) 26 | .addUserOption((option) => 27 | option.setName('user').setDescription('Корисник').setRequired(false), 28 | ); 29 | 30 | export const execute = async (interaction: ChatInputCommandInteraction) => { 31 | const keyword = interaction.options.getString('link', true); 32 | const user = interaction.options.getUser('user'); 33 | 34 | const link = await getClosestLink(keyword); 35 | 36 | if (link === null) { 37 | await interaction.editReply(commandErrors.linkNotFound); 38 | 39 | return; 40 | } 41 | 42 | const normalizedUrl = getNormalizedUrl(link.url); 43 | 44 | await interaction.editReply( 45 | user 46 | ? `${commandResponseFunctions.commandFor(user.id)}\n${normalizedUrl}` 47 | : normalizedUrl, 48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /src/commands/list.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ChatInputCommandInteraction, 3 | SlashCommandBuilder, 4 | } from 'discord.js'; 5 | 6 | import { 7 | getListLinksEmbed, 8 | getListQuestionsEmbed, 9 | } from '../components/commands.js'; 10 | import { getLinks } from '../data/api/Link.js'; 11 | import { getQuestions } from '../data/api/Question.js'; 12 | import { 13 | commandDescriptions, 14 | commandErrors, 15 | commandResponseFunctions, 16 | } from '../translations/commands.js'; 17 | 18 | const name = 'list'; 19 | 20 | export const data = new SlashCommandBuilder() 21 | .setName(name) 22 | .setDescription('List') 23 | .addSubcommand((command) => 24 | command 25 | .setName('questions') 26 | .setDescription(commandDescriptions['list questions']) 27 | .addUserOption((option) => 28 | option.setName('user').setDescription('Корисник').setRequired(false), 29 | ), 30 | ) 31 | .addSubcommand((command) => 32 | command 33 | .setName('links') 34 | .setDescription(commandDescriptions['list links']) 35 | .addUserOption((option) => 36 | option.setName('user').setDescription('Корисник').setRequired(false), 37 | ), 38 | ); 39 | 40 | const handleListQuestions = async ( 41 | interaction: ChatInputCommandInteraction, 42 | ) => { 43 | const user = interaction.options.getUser('user'); 44 | 45 | const questions = await getQuestions(); 46 | 47 | if (questions === null) { 48 | await interaction.editReply({ 49 | content: commandErrors.questionsFetchFailed, 50 | }); 51 | 52 | return; 53 | } 54 | 55 | const embed = getListQuestionsEmbed(questions); 56 | await interaction.editReply({ 57 | content: user ? commandResponseFunctions.commandFor(user.id) : null, 58 | embeds: [embed], 59 | }); 60 | }; 61 | 62 | const handleListLinks = async (interaction: ChatInputCommandInteraction) => { 63 | const user = interaction.options.getUser('user'); 64 | 65 | const links = await getLinks(); 66 | 67 | if (links === null) { 68 | await interaction.editReply({ 69 | content: commandErrors.linksFetchFailed, 70 | }); 71 | 72 | return; 73 | } 74 | 75 | const embed = getListLinksEmbed(links); 76 | await interaction.editReply({ 77 | content: user ? commandResponseFunctions.commandFor(user.id) : null, 78 | embeds: [embed], 79 | }); 80 | }; 81 | 82 | const listHandlers = { 83 | links: handleListLinks, 84 | questions: handleListQuestions, 85 | }; 86 | 87 | export const execute = async (interaction: ChatInputCommandInteraction) => { 88 | const subcommand = interaction.options.getSubcommand(true); 89 | 90 | if (subcommand in listHandlers) { 91 | await listHandlers[subcommand as keyof typeof listHandlers](interaction); 92 | } 93 | }; 94 | -------------------------------------------------------------------------------- /src/commands/lottery.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChannelType, 3 | type ChatInputCommandInteraction, 4 | type GuildTextBasedChannel, 5 | SlashCommandBuilder, 6 | } from 'discord.js'; 7 | 8 | import { PollCategory } from '../lib/schemas/PollCategory.js'; 9 | import { 10 | commandDescriptions, 11 | commandErrorFunctions, 12 | commandErrors, 13 | commandResponses, 14 | } from '../translations/commands.js'; 15 | import { 16 | endLotteryPoll, 17 | getLotteryPollInformation, 18 | } from '../utils/polls/core/lottery.js'; 19 | 20 | const name = 'lottery'; 21 | 22 | export const data = new SlashCommandBuilder() 23 | .setName(name) 24 | .setDescription('Lottery') 25 | .addSubcommand((command) => 26 | command 27 | .setName('end') 28 | .setDescription(commandDescriptions['lottery end']) 29 | .addStringOption((option) => 30 | option.setName('poll').setDescription('Анкета').setRequired(true), 31 | ) 32 | .addChannelOption((option) => 33 | option 34 | .setName('channel') 35 | .setDescription('Канал') 36 | .setRequired(true) 37 | .addChannelTypes(ChannelType.GuildText), 38 | ) 39 | .addBooleanOption((option) => 40 | option.setName('draw').setDescription('Извлечи победници'), 41 | ), 42 | ); 43 | 44 | const handleLotteryEnd = async (interaction: ChatInputCommandInteraction) => { 45 | const pollId = interaction.options.getString('poll', true); 46 | const channel = interaction.options.getChannel( 47 | 'channel', 48 | true, 49 | ) as GuildTextBasedChannel; 50 | const drawWinners = interaction.options.getBoolean('draw') ?? true; 51 | 52 | const message = await channel.messages.fetch(pollId); 53 | 54 | if (message.poll === null) { 55 | await interaction.editReply(commandErrors.pollNotFound); 56 | 57 | return; 58 | } 59 | 60 | const { pollType } = getLotteryPollInformation(message.content); 61 | 62 | if (pollType === null) { 63 | await interaction.editReply( 64 | commandErrorFunctions.pollNotOfCategory(PollCategory.LOTTERY), 65 | ); 66 | 67 | return; 68 | } 69 | 70 | await endLotteryPoll(message.poll, drawWinners); 71 | 72 | await interaction.editReply(commandResponses.lotteryEnded); 73 | }; 74 | 75 | const lotteryHandlers = { 76 | end: handleLotteryEnd, 77 | }; 78 | 79 | export const execute = async (interaction: ChatInputCommandInteraction) => { 80 | const subcommand = interaction.options.getSubcommand(); 81 | 82 | if (subcommand in lotteryHandlers) { 83 | await lotteryHandlers[subcommand as keyof typeof lotteryHandlers]( 84 | interaction, 85 | ); 86 | } 87 | }; 88 | -------------------------------------------------------------------------------- /src/commands/message.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ChatInputCommandInteraction, 3 | type GuildBasedChannel, 4 | PermissionFlagsBits, 5 | SlashCommandBuilder, 6 | } from 'discord.js'; 7 | 8 | import { 9 | commandDescriptions, 10 | commandErrors, 11 | commandResponses, 12 | } from '../translations/commands.js'; 13 | 14 | const name = 'message'; 15 | const permission = PermissionFlagsBits.Administrator; 16 | 17 | export const data = new SlashCommandBuilder() 18 | .setName(name) 19 | .setDescription(commandDescriptions[name]) 20 | .addChannelOption((option) => 21 | option 22 | .setName('channel') 23 | .setDescription('Канал во кој ќе се испрати пораката') 24 | .setRequired(true), 25 | ) 26 | .addStringOption((option) => 27 | option 28 | .setName('message') 29 | .setDescription('Порака која ќе се испрати') 30 | .setRequired(true), 31 | ) 32 | .setDefaultMemberPermissions(permission); 33 | 34 | export const execute = async (interaction: ChatInputCommandInteraction) => { 35 | const channel = interaction.options.getChannel( 36 | 'channel', 37 | true, 38 | ) as GuildBasedChannel; 39 | const message = interaction.options 40 | .getString('message', true) 41 | .replaceAll(String.raw`\n`, '\n'); 42 | 43 | if (!channel.isTextBased()) { 44 | await interaction.editReply(commandErrors.invalidChannel); 45 | 46 | return; 47 | } 48 | 49 | await channel.send(message); 50 | await interaction.editReply(commandResponses.messageCreated); 51 | }; 52 | -------------------------------------------------------------------------------- /src/commands/office.ts: -------------------------------------------------------------------------------- 1 | import { getCommonCommand } from '../common/commands/classroom.js'; 2 | 3 | const { data, execute } = getCommonCommand('office'); 4 | 5 | export { data, execute }; 6 | -------------------------------------------------------------------------------- /src/commands/ping.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ChatInputCommandInteraction, 3 | SlashCommandBuilder, 4 | } from 'discord.js'; 5 | 6 | import { client } from '../client.js'; 7 | import { 8 | commandDescriptions, 9 | commandResponseFunctions, 10 | } from '../translations/commands.js'; 11 | 12 | const name = 'ping'; 13 | 14 | export const data = new SlashCommandBuilder() 15 | .setName(name) 16 | .setDescription(commandDescriptions[name]); 17 | 18 | export const execute = async (interaction: ChatInputCommandInteraction) => { 19 | await interaction.editReply(commandResponseFunctions.ping(client.ws.ping)); 20 | }; 21 | -------------------------------------------------------------------------------- /src/commands/profile.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ChatInputCommandInteraction, 3 | InteractionContextType, 4 | SlashCommandBuilder, 5 | } from 'discord.js'; 6 | 7 | import { getStudentInfoEmbed } from '../components/commands.js'; 8 | import { 9 | commandDescriptions, 10 | commandErrors, 11 | } from '../translations/commands.js'; 12 | import { getMemberFromGuild } from '../utils/guild.js'; 13 | 14 | const name = 'profile'; 15 | 16 | export const data = new SlashCommandBuilder() 17 | .setName(name) 18 | .setDescription(commandDescriptions[name]) 19 | .addUserOption((option) => 20 | option.setName('user').setDescription('Корисник').setRequired(false), 21 | ) 22 | .setContexts(InteractionContextType.Guild); 23 | 24 | export const execute = async (interaction: ChatInputCommandInteraction) => { 25 | const user = interaction.options.getUser('user') ?? interaction.user; 26 | const member = await getMemberFromGuild(user.id, interaction.guild); 27 | 28 | if (member === null) { 29 | await interaction.editReply(commandErrors.userNotFound); 30 | 31 | return; 32 | } 33 | 34 | const embed = getStudentInfoEmbed(member); 35 | await interaction.editReply({ 36 | embeds: [embed], 37 | }); 38 | }; 39 | -------------------------------------------------------------------------------- /src/commands/prompt.ts: -------------------------------------------------------------------------------- 1 | import { getCommonCommand } from '../common/commands/prompt.js'; 2 | 3 | const { data, execute } = getCommonCommand('prompt'); 4 | 5 | export { data, execute }; 6 | -------------------------------------------------------------------------------- /src/commands/purge.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ChatInputCommandInteraction, 3 | InteractionContextType, 4 | PermissionFlagsBits, 5 | SlashCommandBuilder, 6 | } from 'discord.js'; 7 | import { setTimeout } from 'node:timers/promises'; 8 | 9 | import { 10 | commandDescriptions, 11 | commandErrors, 12 | commandResponseFunctions, 13 | } from '../translations/commands.js'; 14 | 15 | const name = 'purge'; 16 | const permission = PermissionFlagsBits.ManageMessages; 17 | 18 | export const data = new SlashCommandBuilder() 19 | .setName(name) 20 | .setDescription(commandDescriptions[name]) 21 | .addNumberOption((option) => 22 | option 23 | .setName('count') 24 | .setDescription('Број на пораки (меѓу 1 и 100)') 25 | .setMinValue(1) 26 | .setMaxValue(100) 27 | .setRequired(true), 28 | ) 29 | .setContexts(InteractionContextType.Guild) 30 | .setDefaultMemberPermissions(permission); 31 | 32 | export const execute = async (interaction: ChatInputCommandInteraction) => { 33 | if ( 34 | interaction.channel === null || 35 | !interaction.channel.isTextBased() || 36 | interaction.channel.isDMBased() 37 | ) { 38 | await interaction.editReply(commandErrors.serverOnlyCommand); 39 | 40 | return; 41 | } 42 | 43 | const count = Math.round(interaction.options.getNumber('count', true)); 44 | 45 | await interaction.editReply(commandResponseFunctions.deletingMessages(count)); 46 | await setTimeout(500); 47 | await interaction.deleteReply(); 48 | await interaction.channel.bulkDelete(count, true); 49 | }; 50 | -------------------------------------------------------------------------------- /src/commands/query.ts: -------------------------------------------------------------------------------- 1 | import { getCommonCommand } from '../common/commands/prompt.js'; 2 | 3 | const { data, execute } = getCommonCommand('query'); 4 | 5 | export { data, execute }; 6 | -------------------------------------------------------------------------------- /src/commands/question.ts: -------------------------------------------------------------------------------- 1 | import { getCommonCommand } from '../common/commands/faq.js'; 2 | 3 | const { data, execute } = getCommonCommand('question'); 4 | 5 | export { data, execute }; 6 | -------------------------------------------------------------------------------- /src/commands/script.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChannelType, 3 | type ChatInputCommandInteraction, 4 | InteractionContextType, 5 | SlashCommandBuilder, 6 | } from 'discord.js'; 7 | 8 | import { 9 | getSpecialRequestComponents, 10 | getSpecialRequestEmbed, 11 | } from '../components/scripts.js'; 12 | import { getTicketCreateComponents } from '../components/tickets.js'; 13 | import { getTicketingProperty } from '../configuration/main.js'; 14 | import { logger } from '../logger.js'; 15 | import { 16 | commandDescriptions, 17 | commandErrors, 18 | commandResponses, 19 | } from '../translations/commands.js'; 20 | import { logErrorFunctions } from '../translations/logs.js'; 21 | import { 22 | ticketMessageFunctions, 23 | ticketMessages, 24 | } from '../translations/tickets.js'; 25 | 26 | const name = 'script'; 27 | 28 | export const data = new SlashCommandBuilder() 29 | .setName(name) 30 | .setDescription('Script') 31 | .addSubcommand((command) => 32 | command 33 | .setName('special') 34 | .setDescription(commandDescriptions['script special']) 35 | .addChannelOption((option) => 36 | option.setName('channel').setDescription('Канал').setRequired(true), 37 | ), 38 | ) 39 | .addSubcommand((command) => 40 | command 41 | .setName('tickets') 42 | .setDescription('Register commands') 43 | .addChannelOption((option) => 44 | option.setName('channel').setDescription('Канал').setRequired(true), 45 | ), 46 | ) 47 | .setContexts(InteractionContextType.Guild); 48 | 49 | const handleScriptSpecial = async ( 50 | interaction: ChatInputCommandInteraction<'cached'>, 51 | ) => { 52 | const channel = interaction.options.getChannel('channel', true); 53 | 54 | if (!channel.isTextBased() || channel.isDMBased()) { 55 | await interaction.editReply(commandErrors.invalidChannel); 56 | 57 | return; 58 | } 59 | 60 | const embed = getSpecialRequestEmbed(); 61 | const components = getSpecialRequestComponents(); 62 | try { 63 | await channel.send({ 64 | components, 65 | embeds: [embed], 66 | }); 67 | await interaction.editReply(commandResponses.scriptExecuted); 68 | } catch (error) { 69 | await interaction.editReply(commandErrors.scriptNotExecuted); 70 | logger.error(logErrorFunctions.scriptExecutionError(error)); 71 | } 72 | }; 73 | 74 | const handleScriptTickets = async ( 75 | interaction: ChatInputCommandInteraction<'cached'>, 76 | ) => { 77 | const channel = interaction.options.getChannel('channel', true); 78 | 79 | if (channel.type !== ChannelType.GuildText) { 80 | await interaction.editReply(commandErrors.invalidChannel); 81 | 82 | return; 83 | } 84 | 85 | const tickets = getTicketingProperty('tickets'); 86 | 87 | const components = getTicketCreateComponents(tickets ?? []); 88 | 89 | await channel.send({ 90 | allowedMentions: { 91 | parse: [], 92 | }, 93 | components, 94 | content: 95 | `${ticketMessages.createTicket}\n${ticketMessageFunctions.ticketTypes( 96 | tickets ?? [], 97 | )}`.trim(), 98 | }); 99 | 100 | await interaction.editReply(commandResponses.scriptExecuted); 101 | }; 102 | 103 | const listHandlers = { 104 | special: handleScriptSpecial, 105 | tickets: handleScriptTickets, 106 | }; 107 | 108 | export const execute = async ( 109 | interaction: ChatInputCommandInteraction<'cached'>, 110 | ) => { 111 | const subcommand = interaction.options.getSubcommand(true); 112 | 113 | if (subcommand in listHandlers) { 114 | await listHandlers[subcommand as keyof typeof listHandlers](interaction); 115 | } 116 | }; 117 | -------------------------------------------------------------------------------- /src/commands/session.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ChatInputCommandInteraction, 3 | SlashCommandBuilder, 4 | } from 'discord.js'; 5 | import { access } from 'node:fs/promises'; 6 | 7 | import { getSessions } from '../configuration/files.js'; 8 | import { 9 | commandDescriptions, 10 | commandErrors, 11 | commandResponseFunctions, 12 | } from '../translations/commands.js'; 13 | import { logCommandEvent } from '../utils/analytics.js'; 14 | import { getClosestSession } from '../utils/search.js'; 15 | 16 | const name = 'session'; 17 | 18 | export const data = new SlashCommandBuilder() 19 | .setName(name) 20 | .setDescription(commandDescriptions[name]) 21 | .addStringOption((option) => 22 | option 23 | .setName('session') 24 | .setDescription('Сесија') 25 | .setRequired(true) 26 | .setAutocomplete(true), 27 | ) 28 | .addUserOption((option) => 29 | option.setName('user').setDescription('Корисник').setRequired(false), 30 | ); 31 | 32 | export const execute = async (interaction: ChatInputCommandInteraction) => { 33 | const session = interaction.options.getString('session', true); 34 | const user = interaction.options.getUser('user'); 35 | 36 | const closestSession = getClosestSession(session); 37 | 38 | const information = Object.entries(getSessions()).find( 39 | ([key]) => key.toLowerCase() === closestSession?.toLowerCase(), 40 | ); 41 | 42 | if (information === undefined) { 43 | await interaction.editReply(commandErrors.sessionNotFound); 44 | 45 | return; 46 | } 47 | 48 | const path = `./sessions/${information[1]}`; 49 | 50 | try { 51 | await access(path); 52 | } catch { 53 | await interaction.editReply(commandErrors.sessionNotFound); 54 | 55 | return; 56 | } 57 | 58 | await interaction.editReply({ 59 | content: user ? commandResponseFunctions.commandFor(user.id) : null, 60 | files: [path], 61 | }); 62 | 63 | await logCommandEvent(interaction, 'session', { 64 | session: information[0], 65 | }); 66 | }; 67 | -------------------------------------------------------------------------------- /src/commands/staff.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ChatInputCommandInteraction, 3 | SlashCommandBuilder, 4 | } from 'discord.js'; 5 | 6 | import { getStaffEmbed } from '../components/commands.js'; 7 | import { getStaff } from '../configuration/files.js'; 8 | import { 9 | commandDescriptions, 10 | commandErrors, 11 | commandResponseFunctions, 12 | } from '../translations/commands.js'; 13 | import { logCommandEvent } from '../utils/analytics.js'; 14 | import { getClosestStaff } from '../utils/search.js'; 15 | 16 | const name = 'staff'; 17 | 18 | export const data = new SlashCommandBuilder() 19 | .setName(name) 20 | .setDescription(commandDescriptions[name]) 21 | .addStringOption((option) => 22 | option 23 | .setName('professor') 24 | .setDescription('Професор') 25 | .setRequired(true) 26 | .setAutocomplete(true), 27 | ) 28 | .addUserOption((option) => 29 | option.setName('user').setDescription('Корисник').setRequired(false), 30 | ); 31 | 32 | export const execute = async (interaction: ChatInputCommandInteraction) => { 33 | const professor = interaction.options.getString('professor', true); 34 | const user = interaction.options.getUser('user'); 35 | 36 | const closestStaff = getClosestStaff(professor); 37 | 38 | const information = getStaff().find( 39 | (staff) => staff.name.toLowerCase() === closestStaff?.toLowerCase(), 40 | ); 41 | 42 | if (information === undefined) { 43 | await interaction.editReply(commandErrors.staffNotFound); 44 | 45 | return; 46 | } 47 | 48 | const embed = getStaffEmbed(information); 49 | await interaction.editReply({ 50 | content: user ? commandResponseFunctions.commandFor(user.id) : null, 51 | embeds: [embed], 52 | }); 53 | 54 | await logCommandEvent(interaction, 'staff', { 55 | keyword: professor, 56 | staff: information, 57 | }); 58 | }; 59 | -------------------------------------------------------------------------------- /src/commands/statistics.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ChatInputCommandInteraction, 3 | roleMention, 4 | SlashCommandBuilder, 5 | } from 'discord.js'; 6 | 7 | import { 8 | commandDescriptions, 9 | commandErrors, 10 | commandResponseFunctions, 11 | } from '../translations/commands.js'; 12 | import { 13 | getMaxEmojisByBoostLevel, 14 | getMaxSoundboardSoundsByBoostLevel, 15 | getMaxStickersByBoostLevel, 16 | } from '../utils/boost.js'; 17 | import { getGuild } from '../utils/guild.js'; 18 | import { safeReplyToInteraction } from '../utils/messages.js'; 19 | import { getRoles } from '../utils/roles.js'; 20 | 21 | const name = 'statistics'; 22 | 23 | export const data = new SlashCommandBuilder() 24 | .setName(name) 25 | .setDescription('Color') 26 | .addSubcommand((command) => 27 | command 28 | .setName('color') 29 | .setDescription(commandDescriptions['statistics color']), 30 | ) 31 | .addSubcommand((command) => 32 | command 33 | .setName('program') 34 | .setDescription(commandDescriptions['statistics program']), 35 | ) 36 | .addSubcommand((command) => 37 | command 38 | .setName('year') 39 | .setDescription(commandDescriptions['statistics year']), 40 | ) 41 | .addSubcommand((command) => 42 | command 43 | .setName('course') 44 | .setDescription(commandDescriptions['statistics course']), 45 | ) 46 | .addSubcommand((command) => 47 | command 48 | .setName('notification') 49 | .setDescription(commandDescriptions['statistics notification']), 50 | ) 51 | .addSubcommand((command) => 52 | command 53 | .setName('server') 54 | .setDescription(commandDescriptions['statistics server']), 55 | ); 56 | 57 | export const execute = async (interaction: ChatInputCommandInteraction) => { 58 | const guild = await getGuild(interaction); 59 | 60 | if (guild === null) { 61 | await interaction.editReply(commandErrors.guildFetchFailed); 62 | 63 | return; 64 | } 65 | 66 | await guild.members.fetch(); 67 | 68 | const subcommand = interaction.options.getSubcommand(true); 69 | 70 | if ( 71 | subcommand === 'color' || 72 | subcommand === 'program' || 73 | subcommand === 'year' || 74 | subcommand === 'course' || 75 | subcommand === 'notification' 76 | ) { 77 | const roles = getRoles( 78 | guild, 79 | subcommand === 'course' ? 'courses' : subcommand, 80 | ); 81 | roles.sort((a, b) => b.members.size - a.members.size); 82 | const output = roles.map( 83 | (role) => `${roleMention(role.id)}: ${role.members.size}`, 84 | ); 85 | 86 | await safeReplyToInteraction(interaction, output.join('\n')); 87 | } else { 88 | const boostLevel = guild.premiumTier; 89 | 90 | await guild.channels.fetch(); 91 | await guild.roles.fetch(); 92 | await guild.emojis.fetch(); 93 | await guild.stickers.fetch(); 94 | await guild.invites.fetch(); 95 | await guild.soundboardSounds.fetch(); 96 | 97 | const output = [ 98 | commandResponseFunctions.serverMembersStat( 99 | guild.memberCount, 100 | guild.maximumMembers, 101 | ), 102 | commandResponseFunctions.serverBoostStat( 103 | guild.premiumSubscriptionCount ?? 0, 104 | ), 105 | commandResponseFunctions.serverBoostLevelStat(guild.premiumTier), 106 | commandResponseFunctions.serverChannelsStat( 107 | guild.channels.cache.filter((channel) => !channel.isThread()).size, 108 | ), 109 | commandResponseFunctions.serverRolesStat(guild.roles.cache.size), 110 | commandResponseFunctions.serverEmojiStat( 111 | guild.emojis.cache.filter((emoji) => !emoji.animated).size, 112 | getMaxEmojisByBoostLevel(boostLevel), 113 | ), 114 | commandResponseFunctions.serverAnimatedEmojiStat( 115 | guild.emojis.cache.filter((emoji) => emoji.animated).size, 116 | getMaxEmojisByBoostLevel(boostLevel), 117 | ), 118 | commandResponseFunctions.serverStickersStat( 119 | guild.stickers.cache.size, 120 | getMaxStickersByBoostLevel(boostLevel), 121 | ), 122 | commandResponseFunctions.serverSoundboardSoundsStat( 123 | guild.soundboardSounds.cache.size, 124 | getMaxSoundboardSoundsByBoostLevel(boostLevel), 125 | ), 126 | commandResponseFunctions.serverInvitesStat(guild.invites.cache.size), 127 | ]; 128 | 129 | await interaction.editReply(output.join('\n')); 130 | } 131 | }; 132 | -------------------------------------------------------------------------------- /src/commands/studentInfo.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApplicationCommandType, 3 | ContextMenuCommandBuilder, 4 | type GuildMember, 5 | InteractionContextType, 6 | type UserContextMenuCommandInteraction, 7 | } from 'discord.js'; 8 | 9 | import { getStudentInfoEmbed } from '../components/commands.js'; 10 | 11 | const name = 'Student Info'; 12 | 13 | export const data = new ContextMenuCommandBuilder() 14 | .setName(name) 15 | .setType(ApplicationCommandType.User) 16 | .setContexts(InteractionContextType.Guild); 17 | 18 | export const execute = async ( 19 | interaction: UserContextMenuCommandInteraction, 20 | ) => { 21 | const embed = getStudentInfoEmbed(interaction.targetMember as GuildMember); 22 | await interaction.editReply({ 23 | embeds: [embed], 24 | }); 25 | }; 26 | -------------------------------------------------------------------------------- /src/commands/ticket.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ChatInputCommandInteraction, 3 | SlashCommandBuilder, 4 | } from 'discord.js'; 5 | 6 | import { getChannelsProperty } from '../configuration/main.js'; 7 | import { Channel } from '../lib/schemas/Channel.js'; 8 | import { 9 | commandDescriptions, 10 | commandErrors, 11 | commandResponses, 12 | } from '../translations/commands.js'; 13 | import { labels } from '../translations/labels.js'; 14 | import { safeReplyToInteraction } from '../utils/messages.js'; 15 | import { getActiveTickets } from '../utils/tickets.js'; 16 | 17 | const name = 'ticket'; 18 | const dateFormatter = new Intl.DateTimeFormat('mk-MK', { 19 | dateStyle: 'long', 20 | timeStyle: 'short', 21 | }); 22 | 23 | export const data = new SlashCommandBuilder() 24 | .setName(name) 25 | .setDescription('Ticket') 26 | .addSubcommand((command) => 27 | command 28 | .setName('close') 29 | .setDescription(commandDescriptions['ticket close']), 30 | ) 31 | .addSubcommand((command) => 32 | command.setName('list').setDescription(commandDescriptions['ticket list']), 33 | ); 34 | 35 | const handleTicketClose = async (interaction: ChatInputCommandInteraction) => { 36 | const ticketsChannel = getChannelsProperty(Channel.Tickets); 37 | 38 | if ( 39 | !interaction.channel?.isThread() || 40 | interaction.channel.parentId !== ticketsChannel 41 | ) { 42 | await interaction.editReply(commandErrors.invalidChannel); 43 | 44 | return; 45 | } 46 | 47 | await interaction.editReply(commandResponses.ticketClosed); 48 | 49 | await interaction.channel.setLocked(true); 50 | await interaction.channel.setArchived(true); 51 | }; 52 | 53 | const handleTicketList = async (interaction: ChatInputCommandInteraction) => { 54 | const ticketThreads = await getActiveTickets(interaction); 55 | 56 | if (ticketThreads === undefined || ticketThreads.size === 0) { 57 | await interaction.editReply(commandErrors.noTickets); 58 | 59 | return; 60 | } 61 | 62 | ticketThreads.sort((a, b) => { 63 | if (!a.createdTimestamp || !b.createdTimestamp) { 64 | return 0; 65 | } 66 | 67 | if (a.createdTimestamp < b.createdTimestamp) { 68 | return -1; 69 | } 70 | 71 | if (a.createdTimestamp > b.createdTimestamp) { 72 | return 1; 73 | } 74 | 75 | return 0; 76 | }); 77 | 78 | const threadLinks = ticketThreads 79 | .map( 80 | (thread) => 81 | `- ${thread.url} (${thread.createdAt ? dateFormatter.format(thread.createdAt) : labels.none})`, 82 | ) 83 | .join('\n'); 84 | 85 | await safeReplyToInteraction(interaction, threadLinks); 86 | }; 87 | 88 | const ticketHandlers = { 89 | close: handleTicketClose, 90 | list: handleTicketList, 91 | }; 92 | 93 | export const execute = async (interaction: ChatInputCommandInteraction) => { 94 | const subcommand = interaction.options.getSubcommand(); 95 | 96 | if (subcommand in ticketHandlers) { 97 | await ticketHandlers[subcommand as keyof typeof ticketHandlers]( 98 | interaction, 99 | ); 100 | } 101 | }; 102 | -------------------------------------------------------------------------------- /src/commands/timeout.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ChatInputCommandInteraction, 3 | type GuildMember, 4 | InteractionContextType, 5 | SlashCommandBuilder, 6 | } from 'discord.js'; 7 | 8 | import { 9 | commandDescriptions, 10 | commandResponses, 11 | } from '../translations/commands.js'; 12 | 13 | const name = 'timeout'; 14 | const options = [ 15 | { 16 | name: '1 мин.', 17 | value: '60000', 18 | }, 19 | { 20 | name: '5 мин.', 21 | value: '300000', 22 | }, 23 | { 24 | name: '15 мин.', 25 | value: '900000', 26 | }, 27 | { 28 | name: '30 мин.', 29 | value: '1800000', 30 | }, 31 | { 32 | name: '1 ч.', 33 | value: '3600000', 34 | }, 35 | { 36 | name: '2 ч.', 37 | value: '7200000', 38 | }, 39 | { 40 | name: '3 ч.', 41 | value: '10800000', 42 | }, 43 | { 44 | name: '4 ч.', 45 | value: '14400000', 46 | }, 47 | { 48 | name: '8 ч.', 49 | value: '28800000', 50 | }, 51 | { 52 | name: '24 ч.', 53 | value: '86400000', 54 | }, 55 | { 56 | name: '1 нед.', 57 | value: '604800000', 58 | }, 59 | ]; 60 | 61 | export const data = new SlashCommandBuilder() 62 | .setName(name) 63 | .setDescription(commandDescriptions[name]) 64 | .addStringOption((option) => 65 | option 66 | .setName('duration') 67 | .setDescription('Времетраење') 68 | .setRequired(true) 69 | .setChoices(options), 70 | ) 71 | .setContexts(InteractionContextType.Guild); 72 | 73 | export const execute = async (interaction: ChatInputCommandInteraction) => { 74 | const member = interaction.member as GuildMember; 75 | const duration = interaction.options.getString('duration', true); 76 | 77 | const timeoutDuration = Number.parseInt(duration); 78 | 79 | try { 80 | await member.timeout(timeoutDuration); 81 | } catch { 82 | await interaction.editReply(commandResponses.timeoutImpossible); 83 | return; 84 | } 85 | 86 | await interaction.editReply(commandResponses.timeoutSet); 87 | }; 88 | -------------------------------------------------------------------------------- /src/common/commands/classroom.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ChatInputCommandInteraction, 3 | SlashCommandBuilder, 4 | } from 'discord.js'; 5 | 6 | import { getClassroomEmbed } from '../../components/commands.js'; 7 | import { getClassrooms } from '../../configuration/files.js'; 8 | import { type Command } from '../../lib/types/Command.js'; 9 | import { 10 | commandDescriptions, 11 | commandErrors, 12 | commandResponseFunctions, 13 | } from '../../translations/commands.js'; 14 | import { logCommandEvent } from '../../utils/analytics.js'; 15 | import { getClosestClassroom } from '../../utils/search.js'; 16 | 17 | export const getCommonCommand = ( 18 | name: keyof typeof commandDescriptions, 19 | ): Command => ({ 20 | data: new SlashCommandBuilder() 21 | .setName(name) 22 | .setDescription(commandDescriptions[name]) 23 | .addStringOption((option) => 24 | option 25 | .setName('classroom') 26 | .setDescription('Просторија') 27 | .setRequired(true) 28 | .setAutocomplete(true), 29 | ) 30 | .addUserOption((option) => 31 | option.setName('user').setDescription('Корисник').setRequired(false), 32 | ), 33 | 34 | execute: async (interaction: ChatInputCommandInteraction) => { 35 | const classroom = interaction.options.getString('classroom', true); 36 | const user = interaction.options.getUser('user'); 37 | 38 | const closestClassroom = getClosestClassroom(classroom) ?? classroom; 39 | 40 | const charPos = closestClassroom.indexOf('('); 41 | const classroomName = 42 | charPos === -1 43 | ? closestClassroom 44 | : closestClassroom.slice(0, charPos).trim(); 45 | const classrooms = getClassrooms().filter( 46 | (cl) => 47 | cl.classroom.toString().toLowerCase() === classroomName.toLowerCase(), 48 | ); 49 | 50 | if (classrooms.length === 0) { 51 | await interaction.editReply({ 52 | content: commandErrors.classroomNotFound, 53 | }); 54 | 55 | return; 56 | } 57 | 58 | const embeds = classrooms.map((cl) => getClassroomEmbed(cl)); 59 | await interaction.editReply({ 60 | content: user ? commandResponseFunctions.commandFor(user.id) : null, 61 | embeds, 62 | }); 63 | 64 | await logCommandEvent(interaction, 'classroom', { 65 | classroom: closestClassroom, 66 | keyword: classroom, 67 | matchedClassrooms: classrooms, 68 | }); 69 | }, 70 | }); 71 | -------------------------------------------------------------------------------- /src/common/commands/faq.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ChatInputCommandInteraction, 3 | SlashCommandBuilder, 4 | } from 'discord.js'; 5 | 6 | import { 7 | getQuestionComponents, 8 | getQuestionEmbed, 9 | } from '../../components/commands.js'; 10 | import { 11 | commandDescriptions, 12 | commandErrors, 13 | commandResponseFunctions, 14 | } from '../../translations/commands.js'; 15 | import { logCommandEvent } from '../../utils/analytics.js'; 16 | import { getClosestQuestion } from '../../utils/search.js'; 17 | 18 | export const getCommonCommand = (name: keyof typeof commandDescriptions) => ({ 19 | data: new SlashCommandBuilder() 20 | .setName(name) 21 | .setDescription(commandDescriptions[name]) 22 | .addStringOption((option) => 23 | option 24 | .setName('question') 25 | .setDescription('Прашање') 26 | .setRequired(true) 27 | .setAutocomplete(true), 28 | ) 29 | .addUserOption((option) => 30 | option.setName('user').setDescription('Корисник').setRequired(false), 31 | ), 32 | 33 | execute: async (interaction: ChatInputCommandInteraction) => { 34 | const keyword = interaction.options.getString('question', true); 35 | const user = interaction.options.getUser('user'); 36 | 37 | const question = await getClosestQuestion(keyword); 38 | 39 | if (question === null) { 40 | await interaction.editReply(commandErrors.faqNotFound); 41 | 42 | return; 43 | } 44 | 45 | const embed = getQuestionEmbed(question); 46 | const components = getQuestionComponents(question); 47 | await interaction.editReply({ 48 | components, 49 | content: user ? commandResponseFunctions.commandFor(user.id) : null, 50 | embeds: [embed], 51 | }); 52 | 53 | await logCommandEvent(interaction, 'faq', { 54 | content: question.content, 55 | keyword, 56 | question: question.name, 57 | }); 58 | }, 59 | }); 60 | -------------------------------------------------------------------------------- /src/common/commands/prompt.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ChatInputCommandInteraction, 3 | SlashCommandBuilder, 4 | } from 'discord.js'; 5 | 6 | import { getConfigProperty } from '../../configuration/main.js'; 7 | import { SendPromptOptionsSchema } from '../../lib/schemas/Chat.js'; 8 | import { 9 | commandDescriptions, 10 | commandErrors, 11 | } from '../../translations/commands.js'; 12 | import { logCommandEvent } from '../../utils/analytics.js'; 13 | import { LLM_ERRORS } from '../../utils/chat/errors.js'; 14 | import { sendPrompt } from '../../utils/chat/requests.js'; 15 | import { safeStreamReplyToInteraction } from '../../utils/messages.js'; 16 | 17 | export const getCommonCommand = (name: keyof typeof commandDescriptions) => ({ 18 | data: new SlashCommandBuilder() 19 | .setName(name) 20 | .setDescription(commandDescriptions[name]) 21 | .addStringOption((option) => 22 | option 23 | .setName('prompt') 24 | .setDescription('Промпт за LLM агентот') 25 | .setRequired(true), 26 | ), 27 | 28 | execute: async (interaction: ChatInputCommandInteraction) => { 29 | const prompt = interaction.options.getString('prompt', true); 30 | const systemPrompt = 31 | interaction.options.getString('system-prompt') ?? undefined; 32 | const embeddingsModel = 33 | interaction.options.getString('embeddings-model') ?? undefined; 34 | const inferenceModel = 35 | interaction.options.getString('inference-model') ?? undefined; 36 | const temperature = 37 | interaction.options.getNumber('temperature') ?? undefined; 38 | const topP = interaction.options.getNumber('top-p') ?? undefined; 39 | const maxTokens = interaction.options.getNumber('max-tokens') ?? undefined; 40 | const useAgent = interaction.options.getBoolean('use-agent') ?? undefined; 41 | 42 | const models = getConfigProperty('models'); 43 | 44 | const options = SendPromptOptionsSchema.parse({ 45 | embeddingsModel: embeddingsModel ?? models.embeddings, 46 | inferenceModel: inferenceModel ?? models.inference, 47 | maxTokens, 48 | prompt, 49 | systemPrompt, 50 | temperature, 51 | topP, 52 | useAgent, 53 | }); 54 | 55 | let answer = ''; 56 | 57 | try { 58 | await safeStreamReplyToInteraction(interaction, async (onChunk) => { 59 | await sendPrompt(options, async (chunk) => { 60 | await onChunk(chunk); 61 | answer += chunk; 62 | }); 63 | }); 64 | } catch (error) { 65 | if (!(error instanceof Error)) { 66 | throw error; 67 | } 68 | 69 | const errorMessage = 70 | LLM_ERRORS[error.message] ?? commandErrors.unknownChatError; 71 | 72 | await (interaction.deferred || interaction.replied 73 | ? interaction.editReply(errorMessage) 74 | : interaction.reply({ 75 | content: errorMessage, 76 | ephemeral: true, 77 | })); 78 | } 79 | 80 | await logCommandEvent(interaction, 'prompt', { 81 | answer, 82 | options, 83 | }); 84 | }, 85 | }); 86 | -------------------------------------------------------------------------------- /src/components/pagination.ts: -------------------------------------------------------------------------------- 1 | import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; 2 | 3 | import { type PaginationPosition } from '../lib/types/PaginationPosition.js'; 4 | 5 | export const getPaginationComponents = ( 6 | name: string, 7 | position: PaginationPosition = 'none', 8 | ) => { 9 | if (position === 'none') { 10 | return new ActionRowBuilder().addComponents( 11 | new ButtonBuilder() 12 | .setCustomId(`${name}:first`) 13 | .setEmoji('⏪') 14 | .setStyle(ButtonStyle.Primary) 15 | .setDisabled(true), 16 | new ButtonBuilder() 17 | .setCustomId(`${name}:previous`) 18 | .setEmoji('⬅️') 19 | .setStyle(ButtonStyle.Primary) 20 | .setDisabled(true), 21 | new ButtonBuilder() 22 | .setCustomId(`${name}:next`) 23 | .setEmoji('➡️') 24 | .setStyle(ButtonStyle.Primary) 25 | .setDisabled(true), 26 | new ButtonBuilder() 27 | .setCustomId(`${name}:last`) 28 | .setEmoji('⏩') 29 | .setStyle(ButtonStyle.Primary) 30 | .setDisabled(true), 31 | ); 32 | } 33 | 34 | if (position === 'start') { 35 | return new ActionRowBuilder().addComponents( 36 | new ButtonBuilder() 37 | .setCustomId(`${name}:first`) 38 | .setEmoji('⏪') 39 | .setStyle(ButtonStyle.Primary) 40 | .setDisabled(true), 41 | new ButtonBuilder() 42 | .setCustomId(`${name}:previous`) 43 | .setEmoji('⬅️') 44 | .setStyle(ButtonStyle.Primary) 45 | .setDisabled(true), 46 | new ButtonBuilder() 47 | .setCustomId(`${name}:next`) 48 | .setEmoji('➡️') 49 | .setStyle(ButtonStyle.Primary), 50 | new ButtonBuilder() 51 | .setCustomId(`${name}:last`) 52 | .setEmoji('⏩') 53 | .setStyle(ButtonStyle.Primary), 54 | ); 55 | } else if (position === 'middle') { 56 | return new ActionRowBuilder().addComponents( 57 | new ButtonBuilder() 58 | .setCustomId(`${name}:first`) 59 | .setEmoji('⏪') 60 | .setStyle(ButtonStyle.Primary), 61 | new ButtonBuilder() 62 | .setCustomId(`${name}:previous`) 63 | .setEmoji('⬅️') 64 | .setStyle(ButtonStyle.Primary), 65 | new ButtonBuilder() 66 | .setCustomId(`${name}:next`) 67 | .setEmoji('➡️') 68 | .setStyle(ButtonStyle.Primary), 69 | new ButtonBuilder() 70 | .setCustomId(`${name}:last`) 71 | .setEmoji('⏩') 72 | .setStyle(ButtonStyle.Primary), 73 | ); 74 | } else { 75 | return new ActionRowBuilder().addComponents( 76 | new ButtonBuilder() 77 | .setCustomId(`${name}:first`) 78 | .setEmoji('⏪') 79 | .setStyle(ButtonStyle.Primary), 80 | new ButtonBuilder() 81 | .setCustomId(`${name}:previous`) 82 | .setEmoji('⬅️') 83 | .setStyle(ButtonStyle.Primary), 84 | new ButtonBuilder() 85 | .setCustomId(`${name}:next`) 86 | .setEmoji('➡️') 87 | .setStyle(ButtonStyle.Primary) 88 | .setDisabled(true), 89 | new ButtonBuilder() 90 | .setCustomId(`${name}:last`) 91 | .setEmoji('⏩') 92 | .setStyle(ButtonStyle.Primary) 93 | .setDisabled(true), 94 | ); 95 | } 96 | }; 97 | -------------------------------------------------------------------------------- /src/components/reminders.ts: -------------------------------------------------------------------------------- 1 | import { type Reminder } from '@prisma/client'; 2 | import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; 3 | 4 | import { truncateString } from './utils.js'; 5 | 6 | export const getRemindersComponents = (reminders: Reminder[]) => { 7 | const components = []; 8 | 9 | for (let index1 = 0; index1 < reminders.length; index1 += 5) { 10 | const row = new ActionRowBuilder(); 11 | const buttons = []; 12 | 13 | for (let index2 = index1; index2 < index1 + 5; index2++) { 14 | const reminder = reminders[index2]; 15 | 16 | if (reminder === undefined) { 17 | break; 18 | } 19 | 20 | const button = new ButtonBuilder() 21 | .setCustomId(`reminderDelete:${reminder.id}:${reminder.userId}`) 22 | .setLabel(truncateString(`${index2 + 1}. ${reminder.description}`, 80)) 23 | .setStyle(ButtonStyle.Danger); 24 | 25 | buttons.push(button); 26 | } 27 | 28 | row.addComponents(buttons); 29 | components.push(row); 30 | } 31 | 32 | return components; 33 | }; 34 | -------------------------------------------------------------------------------- /src/components/scripts.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ActionRowBuilder, 3 | ButtonBuilder, 4 | ButtonStyle, 5 | EmbedBuilder, 6 | } from 'discord.js'; 7 | 8 | import { getThemeColor } from '../configuration/main.js'; 9 | import { specialStrings } from '../translations/special.js'; 10 | 11 | export const getSpecialRequestEmbed = () => 12 | new EmbedBuilder() 13 | .setColor(getThemeColor()) 14 | .setTitle(specialStrings.requestTitle) 15 | .setDescription(specialStrings.requestText); 16 | 17 | export const getSpecialRequestComponents = () => { 18 | const components = []; 19 | 20 | const row = new ActionRowBuilder(); 21 | row.addComponents( 22 | new ButtonBuilder() 23 | .setCustomId('irregulars:request') 24 | .setLabel(specialStrings.irregularsButton) 25 | .setStyle(ButtonStyle.Secondary), 26 | new ButtonBuilder() 27 | .setCustomId('vip:request') 28 | .setLabel(specialStrings.vipButton) 29 | .setStyle(ButtonStyle.Primary), 30 | ); 31 | components.push(row); 32 | 33 | return components; 34 | }; 35 | 36 | export const getVipConfirmEmbed = () => 37 | new EmbedBuilder() 38 | .setColor(getThemeColor()) 39 | .setTitle(specialStrings.oath) 40 | .setDescription(specialStrings.vipOath); 41 | 42 | export const getVipConfirmComponents = () => { 43 | const components = []; 44 | 45 | const row = new ActionRowBuilder(); 46 | row.addComponents( 47 | new ButtonBuilder() 48 | .setCustomId('vip:confirm') 49 | .setLabel(specialStrings.accept) 50 | .setStyle(ButtonStyle.Success), 51 | ); 52 | components.push(row); 53 | 54 | return components; 55 | }; 56 | 57 | export const getVipAcknowledgeComponents = () => { 58 | const components = []; 59 | 60 | const row = new ActionRowBuilder(); 61 | row.addComponents( 62 | new ButtonBuilder() 63 | .setCustomId('vip:acknowledge') 64 | .setLabel(specialStrings.accept) 65 | .setStyle(ButtonStyle.Success), 66 | ); 67 | components.push(row); 68 | 69 | return components; 70 | }; 71 | 72 | export const getIrregularsConfirmEmbed = () => 73 | new EmbedBuilder() 74 | .setColor(getThemeColor()) 75 | .setTitle(specialStrings.oath) 76 | .setDescription(specialStrings.irregularsOath); 77 | 78 | export const getIrregularsConfirmComponents = () => { 79 | const components = []; 80 | 81 | const row = new ActionRowBuilder(); 82 | row.addComponents( 83 | new ButtonBuilder() 84 | .setCustomId('irregulars:confirm') 85 | .setLabel(specialStrings.accept) 86 | .setStyle(ButtonStyle.Success), 87 | ); 88 | components.push(row); 89 | 90 | return components; 91 | }; 92 | 93 | export const getIrregularsAcknowledgeComponents = () => { 94 | const components = []; 95 | 96 | const row = new ActionRowBuilder(); 97 | row.addComponents( 98 | new ButtonBuilder() 99 | .setCustomId('irregulars:acknowledge') 100 | .setLabel(specialStrings.accept) 101 | .setStyle(ButtonStyle.Success), 102 | ); 103 | components.push(row); 104 | 105 | return components; 106 | }; 107 | -------------------------------------------------------------------------------- /src/components/tickets.ts: -------------------------------------------------------------------------------- 1 | import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; 2 | 3 | import { type Ticket } from '../lib/schemas/Ticket.js'; 4 | import { emojis } from '../translations/emojis.js'; 5 | import { labels } from '../translations/labels.js'; 6 | 7 | export const getTicketCreateComponents = (ticketTypes: Ticket[]) => { 8 | const components = []; 9 | 10 | for (let index1 = 0; index1 < ticketTypes.length; index1 += 5) { 11 | const row = new ActionRowBuilder(); 12 | const buttons = []; 13 | 14 | for (let index2 = index1; index2 < index1 + 5; index2++) { 15 | const ticketType = ticketTypes[index2]; 16 | 17 | if (ticketType === undefined) { 18 | break; 19 | } 20 | 21 | const button = new ButtonBuilder() 22 | .setCustomId(`ticketCreate:${ticketType.id}`) 23 | .setLabel(ticketType.name) 24 | .setStyle(ButtonStyle.Success) 25 | .setEmoji(emojis[(index2 + 1).toString()] ?? '🔒'); 26 | 27 | buttons.push(button); 28 | } 29 | 30 | row.addComponents(buttons); 31 | components.push(row); 32 | } 33 | 34 | return components; 35 | }; 36 | 37 | export const getTicketCloseComponents = (ticketId: string) => { 38 | const row = new ActionRowBuilder(); 39 | const button = new ButtonBuilder() 40 | .setCustomId(`ticketClose:${ticketId}`) 41 | .setLabel(labels.close) 42 | .setStyle(ButtonStyle.Danger) 43 | .setEmoji('🔒'); 44 | 45 | row.addComponents(button); 46 | 47 | return [row]; 48 | }; 49 | -------------------------------------------------------------------------------- /src/components/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ButtonInteraction, 3 | channelMention, 4 | type ChatInputCommandInteraction, 5 | inlineCode, 6 | type Interaction, 7 | type MessageContextMenuCommandInteraction, 8 | roleMention, 9 | type UserContextMenuCommandInteraction, 10 | } from 'discord.js'; 11 | 12 | import { getStaff } from '../configuration/files.js'; 13 | import { logger } from '../logger.js'; 14 | import { embedLabels } from '../translations/embeds.js'; 15 | import { labels } from '../translations/labels.js'; 16 | import { logErrorFunctions } from '../translations/logs.js'; 17 | import { getRoleFromSet } from '../utils/roles.js'; 18 | 19 | export const truncateString = ( 20 | string: null | string | undefined, 21 | length = 100, 22 | ) => { 23 | if (string === null || string === undefined) { 24 | return ''; 25 | } 26 | 27 | return string.length > length 28 | ? `${string.slice(0, Math.max(0, length - 3))}...` 29 | : string; 30 | }; 31 | 32 | export const getChannelMention = (interaction: Interaction) => { 33 | if (interaction.channel === null || interaction.channel.isDMBased()) { 34 | return labels.dm; 35 | } 36 | 37 | return channelMention(interaction.channel.id); 38 | }; 39 | 40 | export const getButtonCommand = (command: string) => { 41 | switch (command) { 42 | case 'addCourses': 43 | return embedLabels.addCourses; 44 | 45 | case 'pollStats': 46 | return embedLabels.pollStats; 47 | 48 | case 'removeCourses': 49 | return embedLabels.removeCourses; 50 | 51 | case 'ticketClose': 52 | return embedLabels.ticketClose; 53 | 54 | case 'ticketCreate': 55 | return embedLabels.ticketCreate; 56 | 57 | default: 58 | return command.slice(0, 1).toUpperCase() + command.slice(1); 59 | } 60 | }; 61 | 62 | export const getButtonInfo = ( 63 | interaction: ButtonInteraction, 64 | command: string, 65 | args: string[], 66 | ) => { 67 | switch (command) { 68 | case 'addCourses': 69 | case 'exp': 70 | case 'help': 71 | case 'irregulars': 72 | case 'poll': 73 | case 'polls': 74 | case 'pollStats': 75 | case 'removeCourses': 76 | case 'ticketClose': 77 | case 'ticketCreate': 78 | case 'vip': 79 | return { 80 | name: getButtonCommand(command), 81 | value: 82 | args[0] === undefined ? embedLabels.unknown : inlineCode(args[0]), 83 | }; 84 | 85 | case 'color': 86 | case 'notification': 87 | case 'program': 88 | case 'year': 89 | return { 90 | name: getButtonCommand(command), 91 | value: 92 | interaction.guild && args[0] 93 | ? roleMention( 94 | getRoleFromSet(interaction.guild, command, args[0])?.id ?? 95 | embedLabels.unknown, 96 | ) 97 | : embedLabels.unknown, 98 | }; 99 | 100 | case 'course': 101 | return { 102 | name: getButtonCommand(command), 103 | value: 104 | interaction.guild && args[0] 105 | ? roleMention( 106 | getRoleFromSet(interaction.guild, 'courses', args[0])?.id ?? 107 | embedLabels.unknown, 108 | ) 109 | : embedLabels.unknown, 110 | }; 111 | 112 | default: 113 | return { 114 | name: embedLabels.unknown, 115 | value: embedLabels.unknown, 116 | }; 117 | } 118 | }; 119 | 120 | export const linkStaff = (professors: string) => { 121 | if (professors === '') { 122 | return labels.none; 123 | } 124 | 125 | const allStaff = professors 126 | .split('\n') 127 | .map((professor) => [ 128 | professor, 129 | getStaff().find((staff) => professor.includes(staff.name))?.profile, 130 | ]); 131 | 132 | const linkedStaff = allStaff 133 | .map(([professor, finki]) => 134 | finki ? `[${professor}](${finki})` : professor, 135 | ) 136 | .join('\n'); 137 | 138 | if (linkedStaff.length < 1_000) { 139 | return linkedStaff; 140 | } 141 | 142 | return allStaff.map(([professor]) => professor).join('\n'); 143 | }; 144 | 145 | export const fetchMessageUrl = async ( 146 | interaction: 147 | | ChatInputCommandInteraction 148 | | MessageContextMenuCommandInteraction 149 | | UserContextMenuCommandInteraction, 150 | ) => { 151 | if ( 152 | interaction.channel === null || 153 | !interaction.channel.isTextBased() || 154 | interaction.channel.isDMBased() 155 | ) { 156 | return null; 157 | } 158 | 159 | try { 160 | const { url } = await interaction.fetchReply(); 161 | 162 | return { 163 | url, 164 | }; 165 | } catch (error) { 166 | logger.warn(logErrorFunctions.messageUrlFetchError(interaction.id, error)); 167 | 168 | return null; 169 | } 170 | }; 171 | -------------------------------------------------------------------------------- /src/configuration/defaults.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type BotConfig, 3 | type BotConfigKeys, 4 | } from '../lib/schemas/BotConfig.js'; 5 | import { Model } from '../lib/schemas/Model.js'; 6 | 7 | export const DEFAULT_CONFIGURATION = { 8 | channels: undefined, 9 | crossposting: { 10 | channels: [], 11 | enabled: false, 12 | }, 13 | experience: { 14 | enabled: false, 15 | multipliers: undefined, 16 | }, 17 | guild: undefined, 18 | intervals: { 19 | buttonIdle: 60_000, 20 | ephemeralReply: 5_000, 21 | sendReminders: 15_000, 22 | ticketsCheck: 900_000, 23 | }, 24 | models: { 25 | embeddings: Model.BGE_M3, 26 | inference: Model.LLAMA_3_3_70B, 27 | }, 28 | oathEnabled: false, 29 | reactions: { 30 | add: undefined, 31 | remove: undefined, 32 | }, 33 | roles: undefined, 34 | temporaryChannels: undefined, 35 | themeColor: '#313183', 36 | ticketing: { 37 | allowedInactivityDays: 10, 38 | enabled: false, 39 | tickets: undefined, 40 | }, 41 | } as const satisfies BotConfig satisfies Record; 42 | -------------------------------------------------------------------------------- /src/configuration/environment.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable n/prefer-global/process */ 2 | 3 | import { env } from 'node:process'; 4 | import { z } from 'zod'; 5 | 6 | import { configErrors } from '../translations/errors.js'; 7 | 8 | export const getToken = () => { 9 | try { 10 | return z.string().parse(env['TOKEN']); 11 | } catch { 12 | throw new Error(configErrors.noToken); 13 | } 14 | }; 15 | 16 | export const getApplicationId = () => { 17 | try { 18 | return z.string().parse(env['APPLICATION_ID']); 19 | } catch { 20 | throw new Error(configErrors.noApplicationId); 21 | } 22 | }; 23 | 24 | export const getChatbotUrl = () => { 25 | try { 26 | return z 27 | .string() 28 | .transform((url) => (url.endsWith('/') ? url.slice(0, -1) : url)) 29 | .parse(env['CHATBOT_URL']); 30 | } catch { 31 | return null; 32 | } 33 | }; 34 | 35 | export const getAnalyticsUrl = () => { 36 | try { 37 | return z 38 | .string() 39 | .transform((url) => (url.endsWith('/') ? url.slice(0, -1) : url)) 40 | .parse(env['ANALYTICS_URL']); 41 | } catch { 42 | return null; 43 | } 44 | }; 45 | 46 | export const getApiKey = () => { 47 | try { 48 | return z.string().parse(env['API_KEY']); 49 | } catch { 50 | return null; 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /src/configuration/main.ts: -------------------------------------------------------------------------------- 1 | import type { ColorResolvable } from 'discord.js'; 2 | 3 | import { getConfig, setConfig } from '../data/database/Config.js'; 4 | import { 5 | type BotConfig, 6 | type BotConfigKeys, 7 | BotConfigSchema, 8 | type FullyRequiredBotConfig, 9 | } from '../lib/schemas/BotConfig.js'; 10 | import { logger } from '../logger.js'; 11 | import { configErrorFunctions } from '../translations/errors.js'; 12 | import { DEFAULT_CONFIGURATION } from './defaults.js'; 13 | 14 | let config: BotConfig | undefined; 15 | 16 | export const reloadDatabaseConfig = async () => { 17 | const currentConfig = await getConfig(); 18 | 19 | try { 20 | config = BotConfigSchema.parse(currentConfig?.value); 21 | } catch (error) { 22 | config = BotConfigSchema.parse(DEFAULT_CONFIGURATION); 23 | 24 | logger.warn(configErrorFunctions.invalidConfiguration(error)); 25 | } 26 | }; 27 | 28 | export const getConfigProperty = (key: T) => 29 | config?.[key] ?? DEFAULT_CONFIGURATION[key]; 30 | 31 | export const setConfigProperty = async ( 32 | key: T, 33 | value: NonNullable[T], 34 | ) => { 35 | if (config === undefined) { 36 | return null; 37 | } 38 | 39 | config[key] = value; 40 | const newValue = await setConfig(config); 41 | 42 | return newValue?.value ?? null; 43 | }; 44 | 45 | export const getConfigKeys = () => Object.keys(DEFAULT_CONFIGURATION); 46 | 47 | export const getThemeColor = (): ColorResolvable => 48 | (config?.themeColor as ColorResolvable | undefined) ?? 'Random'; 49 | 50 | export const getChannelsProperty = < 51 | T extends keyof FullyRequiredBotConfig['channels'], 52 | >( 53 | key: T, 54 | ) => config?.channels?.[key]; 55 | 56 | export const getCrosspostingProperty = < 57 | T extends keyof FullyRequiredBotConfig['crossposting'], 58 | >( 59 | key: T, 60 | ) => config?.crossposting?.[key]; 61 | 62 | export const getExperienceProperty = < 63 | T extends keyof FullyRequiredBotConfig['experience'], 64 | >( 65 | key: T, 66 | ) => config?.experience?.[key]; 67 | 68 | export const getIntervalsProperty = < 69 | T extends keyof FullyRequiredBotConfig['intervals'], 70 | >( 71 | key: T, 72 | ) => config?.intervals?.[key] ?? DEFAULT_CONFIGURATION.intervals[key]; 73 | 74 | export const getReactionsProperty = < 75 | T extends keyof FullyRequiredBotConfig['reactions'], 76 | >( 77 | key: T, 78 | ) => config?.reactions?.[key]; 79 | 80 | export const getRolesProperty = < 81 | T extends keyof FullyRequiredBotConfig['roles'], 82 | >( 83 | key: T, 84 | ) => config?.roles?.[key]; 85 | 86 | export const getTemporaryChannelsProperty = < 87 | T extends keyof FullyRequiredBotConfig['temporaryChannels'], 88 | >( 89 | key: T, 90 | ) => config?.temporaryChannels?.[key]; 91 | 92 | export const getTicketingProperty = < 93 | T extends keyof FullyRequiredBotConfig['ticketing'], 94 | >( 95 | key: T, 96 | ) => config?.ticketing?.[key] ?? DEFAULT_CONFIGURATION.ticketing[key]; 97 | 98 | export const getTicketProperty = (key: string) => { 99 | const tickets = getTicketingProperty('tickets'); 100 | 101 | return tickets?.find((ticket) => ticket.id === key); 102 | }; 103 | 104 | export const getExperienceMultiplier = (channelId: string) => { 105 | const multipliers = getExperienceProperty('multipliers'); 106 | 107 | return multipliers?.[channelId] ?? 1; 108 | }; 109 | -------------------------------------------------------------------------------- /src/configuration/refresh.ts: -------------------------------------------------------------------------------- 1 | import { type BotConfigKeys } from '../lib/schemas/BotConfig.js'; 2 | import { logger } from '../logger.js'; 3 | import { logMessageFunctions } from '../translations/logs.js'; 4 | import { initializeChannels } from '../utils/channels.js'; 5 | import { initializeRoles } from '../utils/roles.js'; 6 | 7 | export const refreshOnConfigChange = async (property: BotConfigKeys) => { 8 | logger.info(logMessageFunctions.configPropertyChanged(property)); 9 | 10 | switch (property) { 11 | case 'channels': 12 | await initializeChannels(); 13 | break; 14 | 15 | case 'roles': 16 | await initializeRoles(); 17 | break; 18 | 19 | default: 20 | logger.info(logMessageFunctions.noRefreshNeeded(property)); 21 | break; 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /src/data/database/Anto.ts: -------------------------------------------------------------------------------- 1 | import { type Prisma } from '@prisma/client'; 2 | import { randomBytes } from 'node:crypto'; 3 | 4 | import { logger } from '../../logger.js'; 5 | import { databaseErrorFunctions } from '../../translations/database.js'; 6 | import { database } from './connection.js'; 7 | 8 | export const getAntos = async () => { 9 | try { 10 | return await database.anto.findMany(); 11 | } catch (error) { 12 | logger.error(databaseErrorFunctions.getAntosError(error)); 13 | 14 | return null; 15 | } 16 | }; 17 | 18 | export const createAnto = async (anto?: Prisma.AntoCreateInput) => { 19 | if (anto === undefined) { 20 | return null; 21 | } 22 | 23 | try { 24 | return await database.anto.create({ 25 | data: anto, 26 | }); 27 | } catch (error) { 28 | logger.error(databaseErrorFunctions.createAntoError(error)); 29 | 30 | return null; 31 | } 32 | }; 33 | 34 | export const deleteAnto = async (anto?: string) => { 35 | if (anto === undefined) { 36 | return null; 37 | } 38 | 39 | try { 40 | return await database.anto.delete({ 41 | where: { 42 | quote: anto, 43 | }, 44 | }); 45 | } catch (error) { 46 | logger.error(databaseErrorFunctions.deleteAntoError(error)); 47 | 48 | return null; 49 | } 50 | }; 51 | 52 | export const getRandomAnto = async () => { 53 | try { 54 | const count = await database.anto.count(); 55 | const randomNumber = (randomBytes(1).readUInt8(0) / 255) * count; 56 | const skip = Math.floor(randomNumber); 57 | 58 | return await database.anto.findFirst({ 59 | skip, 60 | }); 61 | } catch (error) { 62 | logger.error(databaseErrorFunctions.getRandomAntoError(error)); 63 | 64 | return null; 65 | } 66 | }; 67 | 68 | export const createAntos = async (antos?: Prisma.AntoCreateManyInput[]) => { 69 | if (antos === undefined) { 70 | return null; 71 | } 72 | 73 | try { 74 | return await database.anto.createMany({ 75 | data: antos, 76 | }); 77 | } catch (error) { 78 | logger.error(databaseErrorFunctions.createAntosError(error)); 79 | 80 | return null; 81 | } 82 | }; 83 | -------------------------------------------------------------------------------- /src/data/database/Bar.ts: -------------------------------------------------------------------------------- 1 | import { type Prisma } from '@prisma/client'; 2 | 3 | import { logger } from '../../logger.js'; 4 | import { databaseErrorFunctions } from '../../translations/database.js'; 5 | import { database } from './connection.js'; 6 | 7 | export const getBars = async () => { 8 | try { 9 | return await database.bar.findMany({ 10 | orderBy: { 11 | userId: 'asc', 12 | }, 13 | }); 14 | } catch (error) { 15 | logger.error(databaseErrorFunctions.getBarsError(error)); 16 | 17 | return null; 18 | } 19 | }; 20 | 21 | export const getBarByUserId = async (userId?: string) => { 22 | if (userId === undefined) { 23 | return null; 24 | } 25 | 26 | try { 27 | return await database.bar.findFirst({ 28 | where: { 29 | userId, 30 | }, 31 | }); 32 | } catch (error) { 33 | logger.error(databaseErrorFunctions.getBarByUserIdError(error)); 34 | 35 | return null; 36 | } 37 | }; 38 | 39 | export const createBar = async (bar?: Prisma.BarCreateInput) => { 40 | if (bar === undefined) { 41 | return null; 42 | } 43 | 44 | try { 45 | return await database.bar.create({ 46 | data: bar, 47 | }); 48 | } catch (error) { 49 | logger.error(databaseErrorFunctions.createBarError(error)); 50 | 51 | return null; 52 | } 53 | }; 54 | 55 | export const deleteBar = async (userId?: string) => { 56 | if (userId === undefined) { 57 | return null; 58 | } 59 | 60 | try { 61 | return await database.bar.delete({ 62 | where: { 63 | userId, 64 | }, 65 | }); 66 | } catch (error) { 67 | logger.error(databaseErrorFunctions.deleteBarError(error)); 68 | 69 | return null; 70 | } 71 | }; 72 | -------------------------------------------------------------------------------- /src/data/database/Config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/restrict-template-expressions */ 2 | 3 | import type { BotConfig } from '../../lib/schemas/BotConfig.js'; 4 | 5 | import { logger } from '../../logger.js'; 6 | import { database } from './connection.js'; 7 | 8 | export const getConfig = async () => await database.config.findFirst(); 9 | 10 | export const setConfig = async (config?: BotConfig) => { 11 | if (config === undefined) { 12 | return null; 13 | } 14 | 15 | try { 16 | return await database.config.upsert({ 17 | create: { 18 | name: 'config', 19 | value: config, 20 | }, 21 | update: { 22 | value: config, 23 | }, 24 | where: { 25 | name: 'config', 26 | }, 27 | }); 28 | } catch (error) { 29 | logger.error(`Failed setting config\n${error}`); 30 | 31 | return null; 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /src/data/database/Experience.ts: -------------------------------------------------------------------------------- 1 | import { type Experience, type Prisma } from '@prisma/client'; 2 | 3 | import { logger } from '../../logger.js'; 4 | import { databaseErrorFunctions } from '../../translations/database.js'; 5 | import { database } from './connection.js'; 6 | 7 | export const createExperience = async ( 8 | experience?: Prisma.ExperienceCreateInput, 9 | ) => { 10 | if (experience === undefined) { 11 | return null; 12 | } 13 | 14 | try { 15 | return await database.experience.create({ 16 | data: experience, 17 | }); 18 | } catch (error) { 19 | logger.error(databaseErrorFunctions.createExperienceError(error)); 20 | 21 | return null; 22 | } 23 | }; 24 | 25 | export const getExperienceByUserId = async (userId?: string) => { 26 | if (userId === undefined) { 27 | return null; 28 | } 29 | 30 | try { 31 | return await database.experience.findUnique({ 32 | where: { 33 | userId, 34 | }, 35 | }); 36 | } catch (error) { 37 | logger.error(databaseErrorFunctions.getExperienceByUserIdError(error)); 38 | 39 | return null; 40 | } 41 | }; 42 | 43 | export const getExperienceCount = async () => { 44 | try { 45 | return await database.experience.count(); 46 | } catch (error) { 47 | logger.error(databaseErrorFunctions.getExperienceCountError(error)); 48 | 49 | return null; 50 | } 51 | }; 52 | 53 | export const getExperienceSorted = async (limit?: number) => { 54 | try { 55 | return await database.experience.findMany({ 56 | orderBy: { 57 | experience: 'desc', 58 | }, 59 | ...(limit !== undefined && { take: limit }), 60 | }); 61 | } catch (error) { 62 | logger.error(databaseErrorFunctions.getExperienceSortedError(error)); 63 | return null; 64 | } 65 | }; 66 | 67 | export const updateExperience = async (experience?: Experience) => { 68 | if (experience === undefined) { 69 | return null; 70 | } 71 | 72 | try { 73 | return await database.experience.update({ 74 | data: experience, 75 | where: { 76 | userId: experience.userId, 77 | }, 78 | }); 79 | } catch (error) { 80 | logger.error(databaseErrorFunctions.updateExperienceError(error)); 81 | 82 | return null; 83 | } 84 | }; 85 | -------------------------------------------------------------------------------- /src/data/database/Reminder.ts: -------------------------------------------------------------------------------- 1 | import { type Prisma } from '@prisma/client'; 2 | 3 | import { logger } from '../../logger.js'; 4 | import { databaseErrorFunctions } from '../../translations/database.js'; 5 | import { database } from './connection.js'; 6 | 7 | export const getReminders = async () => { 8 | try { 9 | return await database.reminder.findMany(); 10 | } catch (error) { 11 | logger.error(databaseErrorFunctions.getRemindersError(error)); 12 | 13 | return null; 14 | } 15 | }; 16 | 17 | export const getRemindersByUserId = async (userId?: string) => { 18 | if (userId === undefined) { 19 | return null; 20 | } 21 | 22 | try { 23 | return await database.reminder.findMany({ 24 | orderBy: { 25 | timestamp: 'asc', 26 | }, 27 | where: { 28 | userId, 29 | }, 30 | }); 31 | } catch (error) { 32 | logger.error(databaseErrorFunctions.getRemindersByUserIdError(error)); 33 | 34 | return null; 35 | } 36 | }; 37 | 38 | export const getReminderById = async (reminderId?: string) => { 39 | if (reminderId === undefined) { 40 | return null; 41 | } 42 | 43 | try { 44 | return await database.reminder.findUnique({ 45 | where: { 46 | id: reminderId, 47 | }, 48 | }); 49 | } catch (error) { 50 | logger.error(databaseErrorFunctions.getReminderByIdError(error)); 51 | 52 | return null; 53 | } 54 | }; 55 | 56 | export const createReminder = async (reminder?: Prisma.ReminderCreateInput) => { 57 | if (reminder === undefined) { 58 | return null; 59 | } 60 | 61 | try { 62 | return await database.reminder.create({ 63 | data: reminder, 64 | }); 65 | } catch (error) { 66 | logger.error(databaseErrorFunctions.createReminderError(error)); 67 | 68 | return null; 69 | } 70 | }; 71 | 72 | export const deleteReminder = async (reminderId?: string) => { 73 | if (reminderId === undefined) { 74 | return null; 75 | } 76 | 77 | try { 78 | return await database.reminder.delete({ 79 | where: { 80 | id: reminderId, 81 | }, 82 | }); 83 | } catch (error) { 84 | logger.error(databaseErrorFunctions.deleteReminderError(error)); 85 | 86 | return null; 87 | } 88 | }; 89 | -------------------------------------------------------------------------------- /src/data/database/connection.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | 3 | export const database = new PrismaClient(); 4 | -------------------------------------------------------------------------------- /src/events/ClientReady.ts: -------------------------------------------------------------------------------- 1 | import { type ClientEvents, Events } from 'discord.js'; 2 | 3 | import { client as bot } from '../client.js'; 4 | import { Channel } from '../lib/schemas/Channel.js'; 5 | import { logger } from '../logger.js'; 6 | import { bootMessage, logMessageFunctions } from '../translations/logs.js'; 7 | import { getChannel, initializeChannels } from '../utils/channels.js'; 8 | import { initializeCronJobs } from '../utils/cron/main.js'; 9 | import { initializeSpecialPolls } from '../utils/polls/core/special.js'; 10 | import { initializeRoles } from '../utils/roles.js'; 11 | 12 | export const name = Events.ClientReady; 13 | export const once = true; 14 | 15 | export const execute = async (...[client]: ClientEvents[typeof name]) => { 16 | await initializeChannels(); 17 | await initializeRoles(); 18 | await initializeSpecialPolls(); 19 | await client.application.commands.fetch(); 20 | 21 | initializeCronJobs(); 22 | 23 | logger.info(logMessageFunctions.loggedIn(bot.user?.tag)); 24 | 25 | const commandsChannel = getChannel(Channel.Logs); 26 | await commandsChannel?.send(bootMessage(new Date().toUTCString())); 27 | }; 28 | -------------------------------------------------------------------------------- /src/events/GuildMemberAdd.ts: -------------------------------------------------------------------------------- 1 | import { type ClientEvents, Events } from 'discord.js'; 2 | 3 | import { 4 | createExperience, 5 | getExperienceByUserId, 6 | } from '../data/database/Experience.js'; 7 | 8 | export const name = Events.GuildMemberAdd; 9 | 10 | export const execute = async (...[member]: ClientEvents[typeof name]) => { 11 | const experience = await getExperienceByUserId(member.id); 12 | 13 | if (experience === null) { 14 | await createExperience({ 15 | experience: 0n, 16 | lastMessage: new Date(), 17 | level: 0, 18 | messages: 0, 19 | userId: member.id, 20 | }); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /src/events/InteractionCreate.ts: -------------------------------------------------------------------------------- 1 | import { type ClientEvents, Events } from 'discord.js'; 2 | 3 | import { 4 | handleAutocomplete, 5 | handleButton, 6 | handleChatInputCommand, 7 | handleMessageContextMenuCommand, 8 | handleUserContextMenuCommand, 9 | } from '../interactions/handlers.js'; 10 | import { logger } from '../logger.js'; 11 | import { logErrorFunctions } from '../translations/logs.js'; 12 | 13 | export const name = Events.InteractionCreate; 14 | 15 | export const execute = async (...[interaction]: ClientEvents[typeof name]) => { 16 | if (interaction.isChatInputCommand()) { 17 | await handleChatInputCommand(interaction); 18 | } else if (interaction.isButton()) { 19 | await handleButton(interaction); 20 | } else if (interaction.isUserContextMenuCommand()) { 21 | await handleUserContextMenuCommand(interaction); 22 | } else if (interaction.isMessageContextMenuCommand()) { 23 | await handleMessageContextMenuCommand(interaction); 24 | } else if (interaction.isAutocomplete()) { 25 | await handleAutocomplete(interaction); 26 | } else { 27 | logger.warn(logErrorFunctions.unknownInteractionError(interaction.user.id)); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /src/events/MessageCreate.ts: -------------------------------------------------------------------------------- 1 | import { type ClientEvents, Events, type Message } from 'discord.js'; 2 | 3 | import { 4 | getCrosspostingProperty, 5 | getReactionsProperty, 6 | } from '../configuration/main.js'; 7 | import { logger } from '../logger.js'; 8 | import { logErrorFunctions } from '../translations/logs.js'; 9 | import { addExperience } from '../utils/experience.js'; 10 | 11 | export const name = Events.MessageCreate; 12 | 13 | const crosspostingChannels = getCrosspostingProperty('channels'); 14 | const crosspostingEnabled = getCrosspostingProperty('enabled'); 15 | 16 | const crosspost = async (message: Message) => { 17 | if ( 18 | !crosspostingEnabled || 19 | crosspostingChannels?.length === 0 || 20 | !crosspostingChannels?.includes(message.channel.id) 21 | ) { 22 | return; 23 | } 24 | 25 | try { 26 | await message.crosspost(); 27 | } catch (error) { 28 | logger.error(logErrorFunctions.crosspostError(message.channel.id, error)); 29 | } 30 | }; 31 | 32 | const addReaction = async (message: Message) => { 33 | const reactions = getReactionsProperty('add'); 34 | const authorId = message.author.id; 35 | const reaction = reactions?.[authorId]; 36 | 37 | if (reaction === undefined) { 38 | return; 39 | } 40 | 41 | try { 42 | await message.react(reaction); 43 | } catch (error) { 44 | logger.error(logErrorFunctions.addReactionError(error)); 45 | } 46 | }; 47 | 48 | export const execute = async (...[message]: ClientEvents[typeof name]) => { 49 | await crosspost(message); 50 | await addExperience(message); 51 | await addReaction(message); 52 | }; 53 | -------------------------------------------------------------------------------- /src/events/MessagePollVoteAdd.ts: -------------------------------------------------------------------------------- 1 | import { type ClientEvents, Events } from 'discord.js'; 2 | 3 | import { handlePoll } from '../utils/polls/main.js'; 4 | 5 | export const name = Events.MessagePollVoteAdd; 6 | 7 | export const execute = async (...[answer]: ClientEvents[typeof name]) => { 8 | let pollAnswer = answer; 9 | 10 | if (pollAnswer.partial) { 11 | await pollAnswer.poll.fetch(); 12 | const fetchedAnswer = pollAnswer.poll.answers.get(pollAnswer.id); 13 | 14 | if (!fetchedAnswer) { 15 | return; 16 | } 17 | 18 | pollAnswer = fetchedAnswer; 19 | } 20 | 21 | if (!pollAnswer.poll.message.author) { 22 | return; 23 | } 24 | 25 | if (pollAnswer.poll.message.author.id !== pollAnswer.client.user.id) { 26 | return; 27 | } 28 | 29 | if (pollAnswer.poll.partial) { 30 | return; 31 | } 32 | 33 | await handlePoll(pollAnswer.poll); 34 | }; 35 | -------------------------------------------------------------------------------- /src/events/MessagePollVoteRemove.ts: -------------------------------------------------------------------------------- 1 | import { type ClientEvents, Events } from 'discord.js'; 2 | 3 | import { handlePoll } from '../utils/polls/main.js'; 4 | 5 | export const name = Events.MessagePollVoteRemove; 6 | 7 | export const execute = async (...[answer]: ClientEvents[typeof name]) => { 8 | let pollAnswer = answer; 9 | 10 | if (pollAnswer.partial) { 11 | await pollAnswer.poll.fetch(); 12 | const fetchedAnswer = pollAnswer.poll.answers.get(pollAnswer.id); 13 | 14 | if (!fetchedAnswer) { 15 | return; 16 | } 17 | 18 | pollAnswer = fetchedAnswer; 19 | } 20 | 21 | if (!pollAnswer.poll.message.author) { 22 | return; 23 | } 24 | 25 | if (pollAnswer.poll.message.author.id !== pollAnswer.client.user.id) { 26 | return; 27 | } 28 | 29 | if (pollAnswer.poll.partial) { 30 | return; 31 | } 32 | 33 | await handlePoll(pollAnswer.poll); 34 | }; 35 | -------------------------------------------------------------------------------- /src/events/MessageReactionAdd.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ClientEvents, 3 | Events, 4 | type MessageReaction, 5 | type PartialMessageReaction, 6 | } from 'discord.js'; 7 | 8 | import { getReactionsProperty } from '../configuration/main.js'; 9 | import { logger } from '../logger.js'; 10 | import { logErrorFunctions } from '../translations/logs.js'; 11 | 12 | export const name = Events.MessageReactionAdd; 13 | 14 | const removeReaction = async ( 15 | reaction: MessageReaction | PartialMessageReaction, 16 | ) => { 17 | const emojis = getReactionsProperty('remove'); 18 | const authorId = reaction.message.author?.id; 19 | 20 | if (authorId === undefined) { 21 | return; 22 | } 23 | 24 | const emojiReaction = emojis?.[authorId]; 25 | const reactedEmoji = reaction.emoji.name?.toLowerCase(); 26 | 27 | if ( 28 | emojiReaction === undefined || 29 | reactedEmoji === undefined || 30 | reactedEmoji !== emojiReaction 31 | ) { 32 | return; 33 | } 34 | 35 | try { 36 | await reaction.remove(); 37 | } catch (error) { 38 | logger.error(logErrorFunctions.removeReactionError(error)); 39 | } 40 | }; 41 | 42 | export const execute = async (...[reaction]: ClientEvents[typeof name]) => { 43 | await removeReaction(reaction); 44 | }; 45 | -------------------------------------------------------------------------------- /src/events/MessageUpdate.ts: -------------------------------------------------------------------------------- 1 | import { type ClientEvents, Events } from 'discord.js'; 2 | 3 | import { handlePoll } from '../utils/polls/main.js'; 4 | 5 | export const name = Events.MessageUpdate; 6 | 7 | export const execute = async (...[, message]: ClientEvents[typeof name]) => { 8 | if (message.poll === null) { 9 | return; 10 | } 11 | 12 | if (message.poll.message.author.id !== message.client.user.id) { 13 | return; 14 | } 15 | 16 | await handlePoll(message.poll); 17 | }; 18 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { client } from './client.js'; 2 | import { getToken } from './configuration/environment.js'; 3 | import { reloadConfigurationFiles } from './configuration/files.js'; 4 | import { reloadDatabaseConfig } from './configuration/main.js'; 5 | import { logErrorFunctions } from './translations/logs.js'; 6 | import { registerCommands } from './utils/commands.js'; 7 | import { attachEventListeners } from './utils/events.js'; 8 | import { attachProcessListeners } from './utils/process.js'; 9 | 10 | await Promise.all([reloadDatabaseConfig(), reloadConfigurationFiles()]); 11 | 12 | attachProcessListeners(); 13 | await registerCommands(); 14 | await attachEventListeners(); 15 | 16 | // Login 17 | 18 | try { 19 | await client.login(getToken()); 20 | } catch (error) { 21 | throw new Error(logErrorFunctions.loginFailed(error)); 22 | } 23 | -------------------------------------------------------------------------------- /src/interactions/utils.ts: -------------------------------------------------------------------------------- 1 | export const createOptions = (options: Array<[string, string]>, term: string) => 2 | options 3 | .filter(([key]) => key.toLowerCase().includes(term.toLowerCase())) 4 | .map(([, value]) => ({ 5 | name: value, 6 | value, 7 | })) 8 | .filter( 9 | (element, index, array) => 10 | array.findIndex((item) => item.name === element.name) === index, 11 | ) 12 | .slice(0, 25); 13 | -------------------------------------------------------------------------------- /src/lib/schemas/Analytics.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | 3 | import { z } from 'zod'; 4 | 5 | export const UsageEventSchema = z 6 | .object({ 7 | eventType: z.string().min(1), 8 | metadata: z.record(z.string(), z.any()).optional(), 9 | payload: z.record(z.string(), z.any()), 10 | }) 11 | .transform((data) => ({ 12 | event_type: data.eventType, 13 | metadata: data.metadata, 14 | payload: data.payload, 15 | })); 16 | 17 | export type UsageEvent = z.infer; 18 | 19 | export const IngestResponseSchema = z.object({ 20 | event_id: z.string(), 21 | event_type: z.string(), 22 | inserted_id: z.string(), 23 | status: z.literal('ok'), 24 | }); 25 | 26 | export type IngestResponse = z.infer; 27 | -------------------------------------------------------------------------------- /src/lib/schemas/Anto.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const AntosSchema = z.array(z.string()); 4 | -------------------------------------------------------------------------------- /src/lib/schemas/BotConfig.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | import { ChannelSchema, TemporaryChannelSchema } from './Channel.js'; 4 | import { ModelSchema } from './Model.js'; 5 | import { RoleSchema } from './Role.js'; 6 | import { TicketSchema } from './Ticket.js'; 7 | 8 | const TemporaryChannelConfigSchema = z.object({ 9 | cron: z.string(), 10 | name: z.string(), 11 | parent: z.string().optional(), 12 | }); 13 | 14 | const ModelsSchema = z.object({ 15 | embeddings: ModelSchema.optional(), 16 | inference: ModelSchema.optional(), 17 | }); 18 | 19 | export const RequiredBotConfigSchema = z.object({ 20 | channels: z.record(ChannelSchema, z.string().optional()).optional(), 21 | crossposting: z 22 | .object({ 23 | channels: z.array(z.string()).optional(), 24 | enabled: z.boolean().optional(), 25 | }) 26 | .optional(), 27 | experience: z 28 | .object({ 29 | enabled: z.boolean().optional(), 30 | multipliers: z.record(z.string(), z.number()).optional(), 31 | }) 32 | .optional(), 33 | guild: z.string().optional(), 34 | intervals: z 35 | .object({ 36 | buttonIdle: z.number().optional(), 37 | ephemeralReply: z.number().optional(), 38 | sendReminders: z.number().optional(), 39 | ticketsCheck: z.number().optional(), 40 | }) 41 | .optional(), 42 | models: ModelsSchema.optional(), 43 | oathEnabled: z.boolean().optional(), 44 | reactions: z 45 | .object({ 46 | add: z.record(z.string(), z.string()).optional(), 47 | remove: z.record(z.string(), z.string()).optional(), 48 | }) 49 | .optional(), 50 | roles: z.record(RoleSchema, z.string()).optional(), 51 | temporaryChannels: z 52 | .record(TemporaryChannelSchema, TemporaryChannelConfigSchema) 53 | .optional(), 54 | themeColor: z.string().optional(), 55 | ticketing: z 56 | .object({ 57 | allowedInactivityDays: z.number().optional(), 58 | enabled: z.boolean().optional(), 59 | tickets: z.array(TicketSchema).optional(), 60 | }) 61 | .optional(), 62 | }); 63 | 64 | export const BotConfigSchema = RequiredBotConfigSchema.optional(); 65 | export type BotConfig = z.infer; 66 | 67 | export const BotConfigKeysSchema = RequiredBotConfigSchema.keyof(); 68 | export type BotConfigKeys = keyof NonNullable; 69 | 70 | export const FullyRequiredBotConfigSchema = RequiredBotConfigSchema.required(); 71 | export type FullyRequiredBotConfig = z.infer< 72 | typeof FullyRequiredBotConfigSchema 73 | >; 74 | -------------------------------------------------------------------------------- /src/lib/schemas/Channel.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export enum Channel { 4 | Activity = 'activity', 5 | Irregulars = 'irregulars', 6 | Logs = 'logs', 7 | Management = 'management', 8 | Oath = 'oath', 9 | Regulars = 'regulars', 10 | Starboard = 'starboard', 11 | Tickets = 'tickets', 12 | VIP = 'vip', 13 | } 14 | 15 | export const ChannelSchema = z.enum(Channel); 16 | 17 | export enum TemporaryChannel { 18 | Regulars = 'regulars', 19 | VIP = 'vip', 20 | } 21 | 22 | export const TemporaryChannelSchema = z.enum(TemporaryChannel); 23 | -------------------------------------------------------------------------------- /src/lib/schemas/Chat.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | 3 | import { z } from 'zod'; 4 | 5 | export const SendPromptOptionsSchema = z 6 | .object({ 7 | embeddingsModel: z.string().optional(), 8 | inferenceModel: z.string().optional(), 9 | maxTokens: z.number().min(1).max(4_096).optional(), 10 | prompt: z.string().min(1, 'Query must not be empty'), 11 | systemPrompt: z.string().optional(), 12 | temperature: z.number().min(0).max(1).optional(), 13 | topP: z.number().min(0).max(1).optional(), 14 | useAgent: z.boolean().optional(), 15 | }) 16 | .transform((data) => ({ 17 | embeddings_model: data.embeddingsModel, 18 | inference_model: data.inferenceModel, 19 | max_tokens: data.maxTokens, 20 | prompt: data.prompt, 21 | system_prompt: data.systemPrompt, 22 | temperature: data.temperature, 23 | top_p: data.topP, 24 | use_agent: data.useAgent, 25 | })); 26 | 27 | export type SendPromptOptions = z.infer; 28 | 29 | export const ClosestQuestionsOptionsSchema = z 30 | .object({ 31 | embeddingsModel: z.string().optional(), 32 | limit: z.number().min(1).max(100).optional(), 33 | prompt: z.string().min(1, 'Query must not be empty'), 34 | threshold: z.number().min(0).max(1).optional(), 35 | }) 36 | .transform((data) => ({ 37 | embeddings_model: data.embeddingsModel, 38 | limit: data.limit, 39 | prompt: data.prompt, 40 | threshold: data.threshold, 41 | })); 42 | 43 | export type ClosestQuestionsOptions = z.infer< 44 | typeof ClosestQuestionsOptionsSchema 45 | >; 46 | 47 | export const FillProgressSchema = z.object({ 48 | error: z.string(), 49 | id: z.string(), 50 | index: z.number(), 51 | model: z.string(), 52 | name: z.string(), 53 | status: z.string(), 54 | total: z.number(), 55 | ts: z.string(), 56 | }); 57 | 58 | export const FillEmbeddingsOptionsSchema = z 59 | .object({ 60 | allModels: z.boolean().optional(), 61 | allQuestions: z.boolean().optional(), 62 | embeddingsModel: z.string().optional(), 63 | questions: z 64 | .array(z.string().min(1, 'Question must not be empty')) 65 | .optional(), 66 | }) 67 | .transform((data) => ({ 68 | all_models: data.allModels, 69 | all_questions: data.allQuestions, 70 | embeddings_model: data.embeddingsModel, 71 | questions: data.questions?.length ? data.questions : undefined, 72 | })); 73 | 74 | export type FillEmbeddingsOptions = z.infer; 75 | 76 | export const UnembeddedQuestionsOptionsSchema = z 77 | .object({ 78 | embeddingsModel: z.string().optional(), 79 | }) 80 | .transform((data) => ({ 81 | embeddings_model: data.embeddingsModel, 82 | })); 83 | 84 | export type UnembeddedQuestionsOptions = z.infer< 85 | typeof UnembeddedQuestionsOptionsSchema 86 | >; 87 | -------------------------------------------------------------------------------- /src/lib/schemas/Classroom.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const ClassroomSchema = z 4 | .object({ 5 | capacity: z.union([z.number(), z.string()]), 6 | classroom: z.union([z.number(), z.string()]), 7 | description: z.string(), 8 | floor: z.union([z.number(), z.string()]), 9 | location: z.string(), 10 | type: z.string(), 11 | }) 12 | .strict(); 13 | 14 | export type Classroom = z.infer; 15 | -------------------------------------------------------------------------------- /src/lib/schemas/ClientEvent.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | const AwaitableVoid = z.union([z.void(), z.promise(z.void())]); 4 | 5 | const createAsyncFunctionSchema = (schema: T) => 6 | z.custom[0]>((fn) => 7 | schema.implementAsync(fn as Parameters[0]), 8 | ); 9 | 10 | export const ClientEventSchema = z.object({ 11 | execute: createAsyncFunctionSchema( 12 | z.function({ 13 | input: [z.unknown()], 14 | output: AwaitableVoid, 15 | }), 16 | ), 17 | name: z.string(), 18 | once: z.boolean().optional(), 19 | }); 20 | -------------------------------------------------------------------------------- /src/lib/schemas/CourseInformation.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const CourseInformationSchema = z 4 | .object({ 5 | code: z.string(), 6 | course: z.string(), 7 | level: z.union([z.number(), z.string()]), 8 | link: z.string(), 9 | }) 10 | .strict(); 11 | 12 | export type CourseInformation = z.infer; 13 | -------------------------------------------------------------------------------- /src/lib/schemas/CourseParticipants.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const CourseParticipantsSchema = z 4 | .object({ 5 | course: z.string(), 6 | }) 7 | .strict() 8 | .catchall(z.number()); 9 | 10 | export type CourseParticipants = z.infer; 11 | -------------------------------------------------------------------------------- /src/lib/schemas/CoursePrerequisites.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const CoursePrerequisitesSchema = z 4 | .object({ 5 | code: z.string(), 6 | course: z.string(), 7 | prerequisite: z.string(), 8 | semester: z.union([z.number(), z.string()]), 9 | }) 10 | .strict(); 11 | 12 | export type CoursePrerequisites = z.infer; 13 | -------------------------------------------------------------------------------- /src/lib/schemas/CourseStaff.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const CourseStaffSchema = z 4 | .object({ 5 | assistants: z.string(), 6 | course: z.string(), 7 | professors: z.string(), 8 | }) 9 | .strict(); 10 | 11 | export type CourseStaff = z.infer; 12 | -------------------------------------------------------------------------------- /src/lib/schemas/LevelConfig.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const LevelConfigSchema = z.record( 4 | z.string(), 5 | z 6 | .object({ 7 | add: z.array(z.string()), 8 | remove: z.array(z.string()), 9 | }) 10 | .strict(), 11 | ); 12 | 13 | export type LevelConfig = z.infer; 14 | -------------------------------------------------------------------------------- /src/lib/schemas/Link.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | 3 | import { z } from 'zod'; 4 | 5 | export const LinkSchema = z 6 | .object({ 7 | created_at: z.string(), 8 | description: z.string().nullable(), 9 | id: z.string(), 10 | name: z.string(), 11 | updated_at: z.string(), 12 | url: z.string(), 13 | user_id: z.string().nullable(), 14 | }) 15 | .transform((data) => ({ 16 | createdAt: new Date(data.created_at), 17 | description: data.description, 18 | id: data.id, 19 | name: data.name, 20 | updatedAt: new Date(data.updated_at), 21 | url: data.url, 22 | userId: data.user_id, 23 | })); 24 | 25 | export const LinksSchema = z.array(LinkSchema); 26 | 27 | export type Link = z.infer; 28 | 29 | export const CreateLinkSchema = z.object({ 30 | description: z.string().nullable(), 31 | name: z.string(), 32 | url: z.string(), 33 | userId: z.string().nullable(), 34 | }); 35 | 36 | export const PreparedCreateLinkSchema = CreateLinkSchema.transform((data) => ({ 37 | description: data.description, 38 | name: data.name, 39 | url: data.url, 40 | user_id: data.userId, 41 | })); 42 | 43 | export type CreateLink = z.infer; 44 | 45 | export const UpdateLinkSchema = z.object({ 46 | description: z.string().nullable(), 47 | name: z.string().nullable(), 48 | url: z.string().nullable(), 49 | userId: z.string().nullable(), 50 | }); 51 | 52 | export const PreparedUpdateLinkSchema = UpdateLinkSchema.transform((data) => ({ 53 | description: data.description, 54 | name: data.name, 55 | url: data.url, 56 | user_id: data.userId, 57 | })); 58 | 59 | export type UpdateLink = z.infer; 60 | -------------------------------------------------------------------------------- /src/lib/schemas/Model.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export enum Model { 4 | BGE_M3 = 'bge-m3:latest', 5 | BGE_M3_GPU_API = 'BAAI/bge-m3', 6 | DEEPSEEK_R1_70B = 'deepseek-r1:70b', 7 | DOMESTIC_YAK_8B_INSTRUCT_GGUF = 'hf.co/LVSTCK/domestic-yak-8B-instruct-GGUF:Q8_0', 8 | GEMINI_2_5_FLASH_PREVIEW_05_20 = 'gemini-2.5-flash-preview-05-20', 9 | GPT_4_1_MINI = 'gpt-4.1-mini', 10 | GPT_4_1_NANO = 'gpt-4.1-nano', 11 | GPT_4O_MINI = 'gpt-4o-mini', 12 | LLAMA_3_3_70B = 'llama3.3:70b', 13 | MISTRAL = 'mistral:latest', 14 | MULTILINGUAL_E5_LARGE = 'intfloat/multilingual-e5-large', 15 | QWEN2_1_5_B_INSTRUCT = 'Qwen/Qwen2-1.5B-Instruct', 16 | QWEN2_5_72B = 'qwen2.5:72b', 17 | TEXT_EMBEDDING_3_LARGE = 'text-embedding-3-large', 18 | TEXT_EMBEDDING_004 = 'models/text-embedding-004', 19 | VEZILKALLM_GGUF = 'hf.co/mradermacher/VezilkaLLM-GGUF:Q8_0', 20 | } 21 | 22 | export const ModelSchema = z.enum(Model); 23 | 24 | export const EMBEDDING_MODELS = [ 25 | Model.BGE_M3, 26 | Model.BGE_M3_GPU_API, 27 | Model.LLAMA_3_3_70B, 28 | Model.TEXT_EMBEDDING_3_LARGE, 29 | Model.TEXT_EMBEDDING_004, 30 | Model.MULTILINGUAL_E5_LARGE, 31 | ] as const; 32 | 33 | export const INFERENCE_MODELS = [ 34 | Model.DEEPSEEK_R1_70B, 35 | Model.DOMESTIC_YAK_8B_INSTRUCT_GGUF, 36 | Model.LLAMA_3_3_70B, 37 | Model.MISTRAL, 38 | Model.QWEN2_5_72B, 39 | Model.GPT_4O_MINI, 40 | Model.VEZILKALLM_GGUF, 41 | Model.GPT_4_1_MINI, 42 | Model.GPT_4_1_NANO, 43 | Model.GEMINI_2_5_FLASH_PREVIEW_05_20, 44 | Model.QWEN2_1_5_B_INSTRUCT, 45 | ] as const; 46 | -------------------------------------------------------------------------------- /src/lib/schemas/PollCategory.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export enum PollCategory { 4 | LOTTERY = 'lottery', 5 | SPECIAL = 'special', 6 | } 7 | 8 | export const PollCategorySchema = z.enum(PollCategory); 9 | -------------------------------------------------------------------------------- /src/lib/schemas/PollType.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export enum LotteryPollType { 4 | REGULARS_LOTTERY = 'regularsLottery', 5 | } 6 | 7 | export enum SpecialPollType { 8 | BAR = 'bar', 9 | IRREGULARS_ADD = 'irregularsAdd', 10 | IRREGULARS_REMOVE = 'irregularsRemove', 11 | IRREGULARS_REQUEST = 'irregularsRequest', 12 | UNBAR = 'unbar', 13 | VIP_ADD = 'vipAdd', 14 | VIP_REMOVE = 'vipRemove', 15 | VIP_REQUEST = 'vipRequest', 16 | } 17 | 18 | export type PollType = LotteryPollType | SpecialPollType; 19 | 20 | export const LotteryPollTypeSchema = z.enum(LotteryPollType); 21 | export const SpecialPollTypeSchema = z.enum(SpecialPollType); 22 | export const PollTypeSchema = z.union([ 23 | LotteryPollTypeSchema, 24 | SpecialPollTypeSchema, 25 | ]); 26 | -------------------------------------------------------------------------------- /src/lib/schemas/Question.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | 3 | import { z } from 'zod'; 4 | 5 | export const QuestionSchema = z 6 | .object({ 7 | content: z.string(), 8 | created_at: z.string(), 9 | distance: z.number().nullish(), 10 | id: z.string(), 11 | links: z.record(z.string(), z.string()).nullable(), 12 | name: z.string(), 13 | updated_at: z.string(), 14 | user_id: z.string().nullable(), 15 | }) 16 | .transform((data) => ({ 17 | content: data.content, 18 | createdAt: new Date(data.created_at), 19 | distance: data.distance ?? undefined, 20 | id: data.id, 21 | links: data.links, 22 | name: data.name, 23 | updatedAt: new Date(data.updated_at), 24 | userId: data.user_id, 25 | })); 26 | 27 | export const QuestionsSchema = z.array(QuestionSchema); 28 | 29 | export type Question = z.infer; 30 | 31 | export const CreateQuestionSchema = z.object({ 32 | content: z.string(), 33 | links: z.record(z.string(), z.string()).nullable(), 34 | name: z.string(), 35 | userId: z.string().nullable(), 36 | }); 37 | 38 | export const PreparedCreateQuestionSchema = CreateQuestionSchema.transform( 39 | (data) => ({ 40 | content: data.content, 41 | links: data.links, 42 | name: data.name, 43 | user_id: data.userId, 44 | }), 45 | ); 46 | 47 | export type CreateQuestion = z.infer; 48 | 49 | export const UpdateQuestionSchema = z.object({ 50 | content: z.string().nullable(), 51 | links: z.record(z.string(), z.string()).nullable(), 52 | name: z.string().nullable(), 53 | userId: z.string().nullable(), 54 | }); 55 | 56 | export const PreparedUpdateQuestionSchema = UpdateQuestionSchema.transform( 57 | (data) => ({ 58 | content: data.content, 59 | links: data.links, 60 | name: data.name, 61 | user_id: data.userId, 62 | }), 63 | ); 64 | 65 | export type UpdateQuestion = z.infer; 66 | -------------------------------------------------------------------------------- /src/lib/schemas/Role.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export enum Role { 4 | Administrators = 'administrators', 5 | Boosters = 'boosters', 6 | Contributors = 'contributors', 7 | FSS = 'fss', 8 | Irregulars = 'irregulars', 9 | Management = 'management', 10 | Moderators = 'moderators', 11 | Ombudsman = 'ombudsman', 12 | Regulars = 'regulars', 13 | VIP = 'vip', 14 | } 15 | 16 | export const RoleSchema = z.enum(Role); 17 | -------------------------------------------------------------------------------- /src/lib/schemas/RoleConfig.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const RoleConfigSchema = z 4 | .object({ 5 | color: z.array(z.string()).optional(), 6 | course: z.record(z.string(), z.array(z.string())).optional(), 7 | courses: z.record(z.string(), z.string()).optional(), 8 | level: z.array(z.string()).optional(), 9 | notification: z.array(z.string()).optional(), 10 | other: z.array(z.string()).optional(), 11 | program: z.array(z.string()).optional(), 12 | year: z.array(z.string()).optional(), 13 | }) 14 | .strict(); 15 | 16 | export type RoleConfig = z.infer; 17 | -------------------------------------------------------------------------------- /src/lib/schemas/Staff.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const StaffSchema = z 4 | .object({ 5 | cabinet: z.union([z.number(), z.string()]), 6 | consultations: z.string(), 7 | courses: z.string(), 8 | email: z.string(), 9 | name: z.string(), 10 | position: z.string(), 11 | profile: z.string(), 12 | title: z.string(), 13 | }) 14 | .strict(); 15 | 16 | export type Staff = z.infer; 17 | -------------------------------------------------------------------------------- /src/lib/schemas/Ticket.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const TicketSchema = z.object({ 4 | description: z.string().optional(), 5 | id: z.string(), 6 | name: z.string(), 7 | roles: z.array(z.string()), 8 | }); 9 | 10 | export type Ticket = z.infer; 11 | -------------------------------------------------------------------------------- /src/lib/types/Command.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ChatInputCommandInteraction, 3 | ContextMenuCommandBuilder, 4 | ContextMenuCommandInteraction, 5 | SlashCommandOptionsOnlyBuilder, 6 | SlashCommandSubcommandsOnlyBuilder, 7 | } from 'discord.js'; 8 | 9 | export type Command = ContextMenuCommand | SlashCommand; 10 | 11 | export type ContextMenuCommand = { 12 | data: ContextMenuCommandBuilder; 13 | execute: (interaction: ContextMenuCommandInteraction) => Promise; 14 | }; 15 | 16 | export type SlashCommand = { 17 | data: SlashCommandOptionsOnlyBuilder | SlashCommandSubcommandsOnlyBuilder; 18 | execute: (interaction: ChatInputCommandInteraction) => Promise; 19 | }; 20 | -------------------------------------------------------------------------------- /src/lib/types/PaginationPosition.ts: -------------------------------------------------------------------------------- 1 | export type PaginationPosition = 'end' | 'middle' | 'none' | 'start'; 2 | -------------------------------------------------------------------------------- /src/lib/types/PartialUser.ts: -------------------------------------------------------------------------------- 1 | import { type User } from 'discord.js'; 2 | 3 | export type PartialUser = Pick; 4 | -------------------------------------------------------------------------------- /src/lib/types/RoleSets.ts: -------------------------------------------------------------------------------- 1 | export type RoleSets = 2 | | 'color' 3 | | 'courses' 4 | | 'notification' 5 | | 'program' 6 | | 'year'; 7 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/restrict-template-expressions */ 2 | 3 | import { createLogger, format, transports } from 'winston'; 4 | 5 | export const logger = createLogger({ 6 | transports: [ 7 | new transports.Console({ 8 | format: format.combine( 9 | format.timestamp({ 10 | format: 'YYYY-MM-DD HH:mm:ss', 11 | }), 12 | format.errors({ 13 | stack: true, 14 | }), 15 | format.colorize({ 16 | colors: { 17 | debug: 'gray', 18 | error: 'red', 19 | http: 'blue', 20 | info: 'green', 21 | silly: 'magenta', 22 | verbose: 'cyan', 23 | warn: 'yellow', 24 | }, 25 | }), 26 | format.printf( 27 | ({ level, message, timestamp }) => 28 | `${timestamp} - ${level}: ${message}`, 29 | ), 30 | ), 31 | handleExceptions: true, 32 | level: 'info', 33 | }), 34 | new transports.File({ 35 | filename: 'logs/bot.log', 36 | format: format.combine( 37 | format.timestamp({ 38 | format: 'YYYY-MM-DD HH:mm:ss', 39 | }), 40 | format.errors({ 41 | stack: true, 42 | }), 43 | format.printf( 44 | ({ level, message, timestamp }) => 45 | `${timestamp} - ${level}: ${message}`, 46 | ), 47 | ), 48 | handleExceptions: true, 49 | level: 'debug', 50 | options: { 51 | flags: 'w', 52 | }, 53 | }), 54 | ], 55 | }); 56 | -------------------------------------------------------------------------------- /src/translations/about.ts: -------------------------------------------------------------------------------- 1 | import { hyperlink, userMention } from 'discord.js'; 2 | 3 | export const botName = 'ФИНКИ Discord Бот'; 4 | 5 | export const aboutMessage = (helpCommand: string, faqCommand: string) => 6 | `Овој бот е развиен од ${userMention( 7 | '198249751001563136', 8 | )} за потребите на Discord серверот на студентите на ФИНКИ. Ботот е open source и може да се најде на ${hyperlink( 9 | 'GitHub', 10 | 'https://github.com/finki-hub/finki-discord-bot', 11 | )}. Ако имате било какви прашања, предлози или проблеми, контактирајте нè на Discord или на GitHub. \n\nНапишете ${helpCommand} за да ги видите сите достапни команди, или ${faqCommand} за да ги видите сите достапни прашања.`; 12 | -------------------------------------------------------------------------------- /src/translations/embeds.ts: -------------------------------------------------------------------------------- 1 | export const embedMessages = { 2 | all: 'Сите', 3 | allCommands: 4 | 'Ова се сите достапни команди за вас. Командите може да ги повикате во овој сервер, или во приватна порака.', 5 | allSpecialPolls: 'Ова се сите достапни специјални анкети.', 6 | breakRules: 'Евентуално кршење на правилата може да доведе до санкции', 7 | chooseNameColor: 'Изберете боја за вашето име.', 8 | chooseNotifications: 9 | 'Изберете за кои типови на објави сакате да добиете нотификации.', 10 | chooseProgram: 'Изберете го смерот на кој студирате.', 11 | chooseSemesterMassCourseAdd: 12 | 'Земете предмети од одредени семестри чии канали сакате да ги гледате.', 13 | chooseSemesterMassCourseRemove: 14 | 'Отстранете предмети од одредени семестри чии канали не сакате да ги гледате.', 15 | chooseYear: 'Изберете ја годината на студирање.', 16 | courseInfo: 'Ова се сите достапни информации за предметот од акредитацијата.', 17 | courseParticipantsInfo: 18 | 'Ова е бројот на студенти кои го запишале предметот за секоја година.', 19 | courseStaffInfo: 20 | 'Ова се професорите и асистентите кои го држеле предметот последните неколку години.', 21 | courseSummaryInfo: 'Ова се сите достапни информации за предметот.', 22 | massCourseAdd: 'Масовно земање предмети', 23 | massCourseRemove: 'Масовно отстранување предмети', 24 | multipleOptions: '(може да изберете повеќе опции)', 25 | nameColor: 'Боја на име', 26 | noCourseInformation: 'Нема информации за предметот.', 27 | notifications: 'Нотификации', 28 | onlyOneOption: 29 | '(може да изберете само една опција, секоја нова опција ја заменува старата)', 30 | pollEnded: 'ГЛАСАЊЕТО Е ЗАВРШЕНО', 31 | pollInformation: 'Информации за анкетата', 32 | semester: 'Семестар', 33 | studentInformation: 'Информации за студентот', 34 | studentNotFound: 'Студентот не е пронајден.', 35 | }; 36 | 37 | export const embedMessageFunctions = { 38 | allLinks: (command: string) => 39 | `Ова се сите достапни линкови. Користете ${command} за да ги добиете линковите.`, 40 | 41 | allPolls: (all: boolean) => `Ова се сите ${all ? '' : 'активни'} анкети.`, 42 | 43 | allQuestions: (command: string) => 44 | `Ова се сите достапни прашања. Користете ${command} за да ги добиете одговорите.`, 45 | 46 | semesterN: (semester: number | string | undefined) => 47 | semester === undefined ? 'Непознат семестар' : `Семестар ${semester}`, 48 | }; 49 | 50 | export const embedLabels = { 51 | addCourses: 'Add Courses', 52 | author: 'Author', 53 | autocompleteInteraction: 'Autocomplete Command', 54 | buttonInteraction: 'Button Command', 55 | channel: 'Channel', 56 | chatInputInteraction: 'Chat Input Command', 57 | command: 'Command', 58 | empty: 'Empty', 59 | messageContextMenuInteraction: 'Message Context Menu Command', 60 | option: 'Option', 61 | pollStats: 'Poll Stats', 62 | removeCourses: 'Remove Courses', 63 | target: 'Target', 64 | ticketClose: 'Ticket Close', 65 | ticketCreate: 'Ticket Create', 66 | unknown: 'Unknown', 67 | userContextMenuInteraction: 'User Context Menu Command', 68 | value: 'Value', 69 | }; 70 | -------------------------------------------------------------------------------- /src/translations/emojis.ts: -------------------------------------------------------------------------------- 1 | export const emojis: Record = { 2 | '0': '0️⃣', 3 | '1': '1️⃣', 4 | '2': '2️⃣', 5 | '3': '3️⃣', 6 | '4': '4️⃣', 7 | '5': '5️⃣', 8 | '6': '6️⃣', 9 | '7': '7️⃣', 10 | '8': '8️⃣', 11 | '9': '9️⃣', 12 | '10': '🔟', 13 | '!': '❗', 14 | '#': '#️⃣', 15 | '*': '*️⃣', 16 | '?': '❓', 17 | a: '🇦', 18 | b: '🇧', 19 | c: '🇨', 20 | d: '🇩', 21 | e: '🇪', 22 | f: '🇫', 23 | g: '🇬', 24 | h: '🇭', 25 | i: '🇮', 26 | j: '🇯', 27 | k: '🇰', 28 | l: '🇱', 29 | m: '🇲', 30 | n: '🇳', 31 | o: '🇴', 32 | p: '🇵', 33 | q: '🇶', 34 | r: '🇷', 35 | s: '🇸', 36 | t: '🇹', 37 | u: '🇺', 38 | v: '🇻', 39 | w: '🇼', 40 | x: '🇽', 41 | y: '🇾', 42 | z: '🇿', 43 | }; 44 | -------------------------------------------------------------------------------- /src/translations/errors.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/restrict-template-expressions */ 2 | 3 | export const configErrors = { 4 | noApplicationId: 'APPLICATION_ID environment variable is not defined', 5 | noToken: 'TOKEN environment variable is not defined', 6 | }; 7 | 8 | export const configErrorFunctions = { 9 | invalidConfiguration: (error: unknown) => `Invalid configuration: ${error}`, 10 | }; 11 | -------------------------------------------------------------------------------- /src/translations/experience.ts: -------------------------------------------------------------------------------- 1 | import { userMention } from 'discord.js'; 2 | 3 | export const experienceMessages = { 4 | levelUp: (userId: string, level: number) => 5 | `Корисникот ${userMention(userId)} достигна ниво ${level}.`, 6 | }; 7 | -------------------------------------------------------------------------------- /src/translations/labels.ts: -------------------------------------------------------------------------------- 1 | export const labels = { 2 | abstain: 'Воздржан', 3 | accreditation: 'Акредитација', 4 | activity: 'Активност', 5 | administration: 'Администрација', 6 | administrators: 'Администратори', 7 | all: 'Сите', 8 | anonymous: 'Анонимно', 9 | assistants: 'Асистенти', 10 | author: 'Автор', 11 | barred: 'Забранети', 12 | boosters: 'Бустери', 13 | boys: 'Boys', 14 | cabinet: 'Кабинет', 15 | capacity: 'Капацитет', 16 | close: 'Затвори', 17 | closed: 'Затворено', 18 | code: 'Код', 19 | color: 'Боја', 20 | commands: 'Команди', 21 | consultations: 'Консултации', 22 | courses: 'Предмети', 23 | description: 'Опис', 24 | dm: 'DM', 25 | done: 'Завршено', 26 | email: 'Електронска пошта', 27 | floor: 'Кат', 28 | girlies: 'Girlies', 29 | irregulars: 'Вонредни', 30 | level: 'Ниво', 31 | link: 'Линк', 32 | links: 'Линкови', 33 | location: 'Локација', 34 | management: 'Управа', 35 | members: 'Членови', 36 | moderators: 'Модератори', 37 | multipleChoice: 'Повеќекратен избор', 38 | no: 'Не', 39 | none: 'Нема', 40 | notifications: 'Нотификации', 41 | open: 'Отворено', 42 | options: 'Опции', 43 | other: 'Друго', 44 | points: 'Поени', 45 | poll: 'Анкета', 46 | polls: 'Анкети', 47 | position: 'Позиција', 48 | prerequisites: 'Предуслови', 49 | professors: 'Професори', 50 | profile: 'Профил', 51 | program: 'Смер', 52 | questions: 'Прашања', 53 | regulars: 'Редовни', 54 | remaining: 'Останати', 55 | reminder: 'Потсетник', 56 | requiredMajority: 'Потребно мнозинство', 57 | result: 'Резултат', 58 | rightToVote: 'Право на глас', 59 | roles: 'Улоги', 60 | rules: 'Правила', 61 | starboard: 'Starboard', 62 | title: 'Звање', 63 | type: 'Тип', 64 | unknown: '?', 65 | vip: 'ВИП', 66 | votersFor: 'Гласачи за', 67 | votes: 'Гласови', 68 | year: 'Година', 69 | yes: 'Да', 70 | }; 71 | -------------------------------------------------------------------------------- /src/translations/pagination.ts: -------------------------------------------------------------------------------- 1 | export const paginationStringFunctions = { 2 | commandPage: (page: number, pages: number, total: number) => 3 | `Страна: ${page} / ${pages} • Команди: ${total}`, 4 | 5 | membersPage: (page: number, pages: number, total: number) => 6 | `Страна: ${page} / ${pages} • Членови: ${total}`, 7 | 8 | pollPage: (page: number, pages: number, total: number) => 9 | `Страна: ${page} / ${pages} • Анкети: ${total}`, 10 | }; 11 | -------------------------------------------------------------------------------- /src/translations/polls.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LotteryPollType, 3 | type PollType, 4 | SpecialPollType, 5 | } from '../lib/schemas/PollType.js'; 6 | 7 | export const POLL_TYPE_LABELS: Record = { 8 | [LotteryPollType.REGULARS_LOTTERY]: 'REGULARS LOTTERY', 9 | 10 | [SpecialPollType.BAR]: 'BAR', 11 | [SpecialPollType.IRREGULARS_ADD]: 'IRREGULARS ADD', 12 | [SpecialPollType.IRREGULARS_REMOVE]: 'IRREGULARS REMOVE', 13 | [SpecialPollType.IRREGULARS_REQUEST]: 'IRREGULARS REQUEST', 14 | [SpecialPollType.UNBAR]: 'UNBAR', 15 | [SpecialPollType.VIP_ADD]: 'VIP ADD', 16 | [SpecialPollType.VIP_REMOVE]: 'VIP REMOVE', 17 | [SpecialPollType.VIP_REQUEST]: 'VIP REQUEST', 18 | }; 19 | -------------------------------------------------------------------------------- /src/translations/tickets.ts: -------------------------------------------------------------------------------- 1 | import { userMention } from 'discord.js'; 2 | 3 | import type { Ticket } from '../lib/schemas/Ticket.js'; 4 | 5 | export const ticketMessages = { 6 | createTicket: `# Тикети\nДоколку имате некакво прашање, проблем, предлог, поплака или слично, може да испратите **приватен** тикет до надлежните луѓе. Изберете го типот на тикетот и напишете го Вашето образложение. Ќе добиете одговор во најбрз можен рок.\n\nМожете да испратите тикет до:`, 7 | sendMessage: 8 | 'Напишете ја Вашата порака во следните 30 минути. Во спротивно, тикетот ќе биде отфрлен.', 9 | }; 10 | 11 | export const ticketMessageFunctions = { 12 | ticketCreated: (userId: string) => 13 | `${userMention(userId)} Ова е Ваш приватен тикет. Образложете го Вашиот проблем или прашање овде. Ќе добиете одговор во најбрз можен рок. Доколку сакате да го затворите тикетот, притиснете го копчето за затворање.`, 14 | ticketLink: (ticketLink: string) => `Креиран е Вашиот тикет: ${ticketLink}`, 15 | ticketStarted: (roles: string) => `${roles} Креиран е тикет до Вас.`, 16 | ticketTypes: (ticketTypes: Ticket[]) => 17 | ticketTypes 18 | .map((ticket) => `- ${ticket.name}: ${ticket.description}`) 19 | .join('\n'), 20 | }; 21 | -------------------------------------------------------------------------------- /src/translations/users.ts: -------------------------------------------------------------------------------- 1 | import { inlineCode, userMention } from 'discord.js'; 2 | 3 | import { type PartialUser } from '../lib/types/PartialUser.js'; 4 | import { labels } from './labels.js'; 5 | 6 | export const tagAndMentionUser = ({ id, tag }: PartialUser) => 7 | `${inlineCode(tag)} (${userMention(id)})`; 8 | 9 | export const formatUsers = (label: string, users: PartialUser[]) => { 10 | users.sort((a, b) => a.tag.localeCompare(b.tag)); 11 | 12 | return `# ${label} (${users.length})\n${ 13 | users.length === 0 14 | ? labels.none 15 | : users 16 | .map((user, index) => `${index}. ${tagAndMentionUser(user)}`) 17 | .join('\n') 18 | }`; 19 | }; 20 | -------------------------------------------------------------------------------- /src/utils/analytics.ts: -------------------------------------------------------------------------------- 1 | import type { ChatInputCommandInteraction } from 'discord.js'; 2 | 3 | import { getAnalyticsUrl, getApiKey } from '../configuration/environment.js'; 4 | import { 5 | IngestResponseSchema, 6 | type UsageEvent, 7 | UsageEventSchema, 8 | } from '../lib/schemas/Analytics.js'; 9 | import { logger } from '../logger.js'; 10 | import { 11 | logErrorFunctions, 12 | logMessageFunctions, 13 | } from '../translations/logs.js'; 14 | 15 | const logEvent = async (event: UsageEvent) => { 16 | const url = getAnalyticsUrl(); 17 | const apiKey = getApiKey(); 18 | 19 | if (url === null || apiKey === null) { 20 | return null; 21 | } 22 | 23 | try { 24 | const res = await fetch(`${url}/events/ingest`, { 25 | body: JSON.stringify(event), 26 | headers: { 27 | 'Content-Type': 'application/json', 28 | 'x-api-key': apiKey, 29 | }, 30 | method: 'POST', 31 | }); 32 | 33 | if (!res.ok) { 34 | return null; 35 | } 36 | 37 | const json = await res.json(); 38 | return IngestResponseSchema.parse(json); 39 | } catch (error) { 40 | logger.warn(logErrorFunctions.logAnalyticsError(error)); 41 | 42 | return null; 43 | } 44 | }; 45 | 46 | export const logCommandEvent = async ( 47 | interaction: ChatInputCommandInteraction, 48 | eventType: string, 49 | basePayload: Record, 50 | logContext = true, 51 | ) => { 52 | if (!interaction.channel?.isTextBased()) { 53 | return; 54 | } 55 | 56 | logger.info(logMessageFunctions.logCommandEvent(interaction.commandName)); 57 | 58 | const targetUser = interaction.options.getUser('user') ?? null; 59 | const metadata: Record = { 60 | callerId: interaction.user.id, 61 | channelId: interaction.channel.id, 62 | commandName: interaction.commandName, 63 | guildId: interaction.guild?.id ?? null, 64 | }; 65 | 66 | const payload: Record = { ...basePayload }; 67 | 68 | if (targetUser) { 69 | metadata['targetUserId'] = targetUser.id; 70 | 71 | const fetched = await interaction.channel.messages.fetch({ 72 | limit: 20, 73 | }); 74 | 75 | const userMessages = Array.from(fetched.values()).filter( 76 | (m) => m.author.id === targetUser.id, 77 | ); 78 | 79 | if (userMessages.length > 0) { 80 | userMessages.sort((a, b) => b.createdTimestamp - a.createdTimestamp); 81 | const last = userMessages[0]; 82 | 83 | payload['targetUserMessage'] = 84 | last === undefined 85 | ? null 86 | : { 87 | content: last.content, 88 | messageId: last.id, 89 | timestamp: new Date(last.createdTimestamp).toISOString(), 90 | }; 91 | } 92 | } else if (logContext) { 93 | const fetched = await interaction.channel.messages.fetch({ limit: 10 }); 94 | const now = Date.now(); 95 | const context = Array.from(fetched.values()) 96 | .filter((m) => !m.author.bot && now - m.createdTimestamp < 15 * 60_000) 97 | .sort((a, b) => a.createdTimestamp - b.createdTimestamp) 98 | .slice(-5) 99 | .map((m) => ({ 100 | authorId: m.author.id, 101 | content: m.content, 102 | messageId: m.id, 103 | timestamp: new Date(m.createdTimestamp).toISOString(), 104 | })); 105 | 106 | if (context.length > 0) { 107 | payload['context'] = context; 108 | } 109 | } 110 | 111 | const event = UsageEventSchema.parse({ 112 | eventType, 113 | metadata, 114 | payload, 115 | }); 116 | 117 | await logEvent(event); 118 | }; 119 | -------------------------------------------------------------------------------- /src/utils/boost.ts: -------------------------------------------------------------------------------- 1 | import { GuildPremiumTier } from 'discord.js'; 2 | 3 | export const getMaxEmojisByBoostLevel = (boostLevel: GuildPremiumTier) => { 4 | switch (boostLevel) { 5 | case GuildPremiumTier.None: 6 | return 50; 7 | 8 | case GuildPremiumTier.Tier1: 9 | return 100; 10 | 11 | case GuildPremiumTier.Tier2: 12 | return 150; 13 | 14 | case GuildPremiumTier.Tier3: 15 | return 250; 16 | 17 | default: 18 | return 50; 19 | } 20 | }; 21 | 22 | export const getMaxStickersByBoostLevel = (boostLevel: GuildPremiumTier) => { 23 | switch (boostLevel) { 24 | case GuildPremiumTier.None: 25 | return 5; 26 | 27 | case GuildPremiumTier.Tier1: 28 | return 15; 29 | 30 | case GuildPremiumTier.Tier2: 31 | return 30; 32 | 33 | case GuildPremiumTier.Tier3: 34 | return 60; 35 | 36 | default: 37 | return 5; 38 | } 39 | }; 40 | 41 | export const getMaxSoundboardSoundsByBoostLevel = ( 42 | boostLevel: GuildPremiumTier, 43 | ) => { 44 | switch (boostLevel) { 45 | case GuildPremiumTier.None: 46 | return 8; 47 | 48 | case GuildPremiumTier.Tier1: 49 | return 24; 50 | 51 | case GuildPremiumTier.Tier2: 52 | return 36; 53 | 54 | case GuildPremiumTier.Tier3: 55 | return 48; 56 | 57 | default: 58 | return 8; 59 | } 60 | }; 61 | -------------------------------------------------------------------------------- /src/utils/chat/errors.ts: -------------------------------------------------------------------------------- 1 | import { commandErrors } from '../../translations/commands.js'; 2 | 3 | export const LLM_ERRORS: Record = { 4 | LLM_DISABLED: commandErrors.llmDisabled, 5 | LLM_NOT_READY: commandErrors.llmNotReady, 6 | LLM_UNAVAILABLE: commandErrors.llmUnavailable, 7 | } as const; 8 | -------------------------------------------------------------------------------- /src/utils/chat/utils.ts: -------------------------------------------------------------------------------- 1 | import { Model } from '../../lib/schemas/Model.js'; 2 | 3 | export const generateModelChoices = (allowedModels: readonly Model[]) => 4 | Object.entries(Model) 5 | .filter(([, value]) => allowedModels.includes(value as Model)) 6 | .map(([key, value]) => ({ name: key, value })); 7 | 8 | export const sanitizeOptions = >(obj: T): T => 9 | Object.fromEntries( 10 | Object.entries(obj).filter(([, value]) => value !== undefined), 11 | ) as T; 12 | -------------------------------------------------------------------------------- /src/utils/commands.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApplicationCommandType, 3 | type ChatInputCommandInteraction, 4 | Collection, 5 | REST, 6 | Routes, 7 | } from 'discord.js'; 8 | import { readdirSync } from 'node:fs'; 9 | 10 | import { client } from '../client.js'; 11 | import { getApplicationId, getToken } from '../configuration/environment.js'; 12 | import { 13 | type Command, 14 | type ContextMenuCommand, 15 | type SlashCommand, 16 | } from '../lib/types/Command.js'; 17 | import { logger } from '../logger.js'; 18 | import { logErrorFunctions, logMessages } from '../translations/logs.js'; 19 | 20 | const commands = new Collection(); 21 | 22 | const refreshCommands = async () => { 23 | const commandFiles = readdirSync('./dist/commands').filter((file) => 24 | file.endsWith('.js'), 25 | ); 26 | 27 | commands.clear(); 28 | 29 | for (const file of commandFiles) { 30 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 31 | const command: Command = await import(`../commands/${file}`); 32 | 33 | commands.set(command.data.name, command); 34 | } 35 | }; 36 | 37 | const isCommandsEmpty = () => commands.entries().next().done; 38 | 39 | export const getCommand = async (commandName: string) => { 40 | if (isCommandsEmpty()) { 41 | await refreshCommands(); 42 | } 43 | 44 | return commands.get(commandName); 45 | }; 46 | 47 | export const isSlashCommand = (command: Command): command is SlashCommand => { 48 | const commandData = command.data.toJSON(); 49 | 50 | return ( 51 | commandData.type === undefined || 52 | commandData.type === ApplicationCommandType.ChatInput 53 | ); 54 | }; 55 | 56 | export const isContextMenuCommand = ( 57 | command: Command, 58 | ): command is ContextMenuCommand => { 59 | const commandData = command.data.toJSON(); 60 | 61 | return ( 62 | commandData.type === ApplicationCommandType.Message || 63 | commandData.type === ApplicationCommandType.User 64 | ); 65 | }; 66 | 67 | export const getCommands = async () => { 68 | if (isCommandsEmpty()) { 69 | await refreshCommands(); 70 | } 71 | 72 | return commands; 73 | }; 74 | 75 | export const commandMention = (name: string | undefined) => { 76 | if (name === undefined) { 77 | return ''; 78 | } 79 | 80 | const command = client.application?.commands.cache.find( 81 | (cmd) => cmd.name === (name.includes(' ') ? name.split(' ')[0] : name), 82 | ); 83 | 84 | if (command === undefined) { 85 | return name; 86 | } 87 | 88 | return ``; 89 | }; 90 | 91 | export const getFullCommandName = ( 92 | interaction: ChatInputCommandInteraction, 93 | ) => { 94 | const subcommand = interaction.options.getSubcommand(false); 95 | 96 | if (subcommand === null) { 97 | return interaction.commandName; 98 | } 99 | 100 | return `${interaction.commandName} ${subcommand}`; 101 | }; 102 | 103 | export const registerCommands = async () => { 104 | const rest = new REST().setToken(getToken()); 105 | const cmds = []; 106 | 107 | for (const [, command] of await getCommands()) { 108 | cmds.push(command.data.toJSON()); 109 | } 110 | 111 | try { 112 | await rest.put(Routes.applicationCommands(getApplicationId()), { 113 | body: cmds, 114 | }); 115 | logger.info(logMessages.commandsRegistered); 116 | } catch (error) { 117 | logger.error(logErrorFunctions.commandsRegistrationError(error)); 118 | } 119 | }; 120 | 121 | export const createCommandChoices = (choices: readonly string[]) => 122 | choices.map((choice) => ({ 123 | name: choice, 124 | value: choice, 125 | })); 126 | -------------------------------------------------------------------------------- /src/utils/cron/constants.ts: -------------------------------------------------------------------------------- 1 | export const DATE_FORMATTER = new Intl.DateTimeFormat('en-CA', { 2 | day: '2-digit', 3 | hour: '2-digit', 4 | hour12: false, 5 | minute: '2-digit', 6 | month: '2-digit', 7 | second: '2-digit', 8 | timeZone: 'UTC', 9 | year: 'numeric', 10 | }); 11 | -------------------------------------------------------------------------------- /src/utils/cron/main.ts: -------------------------------------------------------------------------------- 1 | import { Cron } from 'croner'; 2 | 3 | import { 4 | getIntervalsProperty, 5 | getTemporaryChannelsProperty, 6 | } from '../../configuration/main.js'; 7 | import { TemporaryChannel } from '../../lib/schemas/Channel.js'; 8 | import { logger } from '../../logger.js'; 9 | import { labels } from '../../translations/labels.js'; 10 | import { logMessageFunctions } from '../../translations/logs.js'; 11 | import { 12 | recreateRegularsTemporaryChannel, 13 | recreateVipTemporaryChannel, 14 | } from '../channels.js'; 15 | import { sendReminders } from '../reminders.js'; 16 | import { closeInactiveTickets } from '../tickets.js'; 17 | import { DATE_FORMATTER } from './constants.js'; 18 | 19 | const convertMillisecondsToCronJob = (ms: number) => { 20 | // check if the interval is less than a minute 21 | if (ms < 60_000) { 22 | return `*/${ms / 1_000} * * * * *`; 23 | } 24 | 25 | // check if the interval is less than an hour 26 | if (ms < 3_600_000) { 27 | return `*/${ms / 60_000} * * * *`; 28 | } 29 | 30 | // check if the interval is less than a day 31 | if (ms < 86_400_000) { 32 | return `0 */${ms / 3_600_000} * * *`; 33 | } 34 | 35 | // check if the interval is less than a month 36 | if (ms < 2_592_000_000) { 37 | return `0 0 */${ms / 86_400_000} * *`; 38 | } 39 | 40 | // check if the interval is less than a year 41 | if (ms < 31_536_000_000) { 42 | return `0 0 0 */${ms / 2_592_000_000} *`; 43 | } 44 | 45 | return '0 0 0 0 0'; 46 | }; 47 | 48 | export const initializeCronJobs = () => { 49 | const cronJobs: Cron[] = []; 50 | 51 | const sendRemindersInterval = getIntervalsProperty('sendReminders'); 52 | const ticketsCheckInterval = getIntervalsProperty('ticketsCheck'); 53 | 54 | const vipTemporaryChannel = getTemporaryChannelsProperty( 55 | TemporaryChannel.VIP, 56 | ); 57 | const regularsTemporaryChannel = getTemporaryChannelsProperty( 58 | TemporaryChannel.Regulars, 59 | ); 60 | 61 | cronJobs.push( 62 | new Cron( 63 | convertMillisecondsToCronJob(sendRemindersInterval), 64 | { name: 'sendReminders' }, 65 | sendReminders, 66 | ), 67 | new Cron( 68 | convertMillisecondsToCronJob(ticketsCheckInterval), 69 | { name: 'closeInactiveTickets' }, 70 | closeInactiveTickets, 71 | ), 72 | ); 73 | 74 | if (vipTemporaryChannel !== undefined) { 75 | cronJobs.push( 76 | new Cron( 77 | vipTemporaryChannel.cron, 78 | { name: 'recreateVipTemporaryChannel' }, 79 | recreateVipTemporaryChannel, 80 | ), 81 | ); 82 | } 83 | 84 | if (regularsTemporaryChannel !== undefined) { 85 | cronJobs.push( 86 | new Cron( 87 | regularsTemporaryChannel.cron, 88 | { name: 'recreateRegularsTemporaryChannel' }, 89 | recreateRegularsTemporaryChannel, 90 | ), 91 | ); 92 | } 93 | 94 | for (const job of cronJobs) { 95 | const nextRunDate = job.nextRun(); 96 | const nextRun = 97 | nextRunDate === null 98 | ? labels.unknown 99 | : DATE_FORMATTER.format(nextRunDate); 100 | 101 | logger.info( 102 | logMessageFunctions.cronJobInitialized( 103 | job.name ?? labels.unknown, 104 | nextRun, 105 | ), 106 | ); 107 | } 108 | }; 109 | -------------------------------------------------------------------------------- /src/utils/events.ts: -------------------------------------------------------------------------------- 1 | import { readdirSync } from 'node:fs'; 2 | 3 | import { client } from '../client.js'; 4 | import { ClientEventSchema } from '../lib/schemas/ClientEvent.js'; 5 | 6 | export const attachEventListeners = async () => { 7 | const eventFiles = readdirSync('./dist/events').filter((file) => 8 | file.endsWith('.js'), 9 | ); 10 | 11 | for (const file of eventFiles) { 12 | const rawEvent: unknown = await import(`../events/${file}`); 13 | const event = ClientEventSchema.parse(rawEvent); 14 | 15 | if (event.once) { 16 | client.once(event.name, event.execute); 17 | } else { 18 | client.on(event.name, event.execute); 19 | } 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /src/utils/experience.ts: -------------------------------------------------------------------------------- 1 | import AsyncLock from 'async-lock'; 2 | import { type GuildMember, type Message } from 'discord.js'; 3 | 4 | import { getLevels } from '../configuration/files.js'; 5 | import { 6 | getExperienceMultiplier, 7 | getExperienceProperty, 8 | } from '../configuration/main.js'; 9 | import { 10 | createExperience, 11 | getExperienceByUserId, 12 | updateExperience, 13 | } from '../data/database/Experience.js'; 14 | import { Channel } from '../lib/schemas/Channel.js'; 15 | import { logger } from '../logger.js'; 16 | import { experienceMessages } from '../translations/experience.js'; 17 | import { logErrorFunctions } from '../translations/logs.js'; 18 | import { getChannel } from './channels.js'; 19 | import { EMOJI_REGEX, URL_REGEX } from './regex.js'; 20 | 21 | // Golden ratio 22 | const EXPERIENCE_COEFFICIENT = (1 + Math.sqrt(5)) / 2 - 1; 23 | const LEVEL_DELTA = 800n; 24 | 25 | const cleanMessage = (message: string) => 26 | message.trim().replaceAll(EMOJI_REGEX, '$1').replaceAll(URL_REGEX, ''); 27 | 28 | const countLinks = (message: string) => URL_REGEX.exec(message)?.length ?? 0; 29 | 30 | export const getExperienceFromMessage = async (message: Message) => { 31 | try { 32 | await message.fetch(); 33 | 34 | const multiplier = getExperienceMultiplier(message.channel.id); 35 | 36 | return ( 37 | BigInt(multiplier) * 38 | BigInt( 39 | Math.min( 40 | 50, 41 | Math.floor( 42 | 1 + 43 | 2 * 44 | cleanMessage(message.cleanContent).length ** 45 | EXPERIENCE_COEFFICIENT + 46 | 5 * countLinks(message.cleanContent) ** EXPERIENCE_COEFFICIENT + 47 | 5 * message.attachments.size ** EXPERIENCE_COEFFICIENT + 48 | 5 * message.mentions.users.size ** EXPERIENCE_COEFFICIENT + 49 | 5 * message.mentions.roles.size ** EXPERIENCE_COEFFICIENT + 50 | 5 * message.mentions.channels.size ** EXPERIENCE_COEFFICIENT + 51 | 5 * message.stickers.size, 52 | ), 53 | ), 54 | ) 55 | ); 56 | } catch (error) { 57 | logger.error(logErrorFunctions.messageResolveError(message.id, error)); 58 | 59 | return 0n; 60 | } 61 | }; 62 | 63 | export const getLevelFromExperience = (experience: bigint) => { 64 | let level = 1n; 65 | 66 | while (experience - LEVEL_DELTA * level >= 0) { 67 | // eslint-disable-next-line no-param-reassign 68 | experience -= LEVEL_DELTA * level; 69 | level++; 70 | } 71 | 72 | return Number(level); 73 | }; 74 | 75 | const awardMember = async (member: GuildMember, level: number) => { 76 | const roles = getLevels()[level]; 77 | 78 | if (roles === undefined) { 79 | return; 80 | } 81 | 82 | await member.roles.add(roles.add); 83 | await member.roles.remove(roles.remove); 84 | }; 85 | 86 | const lock = new AsyncLock(); 87 | 88 | export const addExperience = async (message: Message) => { 89 | if (!getExperienceProperty('enabled')) { 90 | return; 91 | } 92 | 93 | if ( 94 | !message.inGuild() || 95 | message.system || 96 | message.author.bot || 97 | message.author.system 98 | ) { 99 | return; 100 | } 101 | 102 | await lock.acquire(message.author.id, async () => { 103 | const currentLevel = 104 | (await getExperienceByUserId(message.author.id)) ?? 105 | (await createExperience({ 106 | experience: 0n, 107 | lastMessage: new Date(), 108 | level: 0, 109 | messages: 0, 110 | userId: message.author.id, 111 | })); 112 | 113 | if (currentLevel === null) { 114 | return; 115 | } 116 | 117 | currentLevel.messages++; 118 | 119 | const experience = await getExperienceFromMessage(message); 120 | currentLevel.experience += experience; 121 | const level = getLevelFromExperience(currentLevel.experience); 122 | 123 | await updateExperience(currentLevel); 124 | 125 | if (level !== currentLevel.level) { 126 | currentLevel.level = level; 127 | 128 | await updateExperience(currentLevel); 129 | 130 | if (message.member === null) { 131 | return; 132 | } 133 | 134 | await awardMember(message.member, level); 135 | 136 | const channel = getChannel(Channel.Activity); 137 | await channel?.send({ 138 | allowedMentions: { 139 | parse: [], 140 | }, 141 | content: experienceMessages.levelUp( 142 | message.author.id, 143 | currentLevel.level, 144 | ), 145 | }); 146 | } 147 | }); 148 | }; 149 | -------------------------------------------------------------------------------- /src/utils/guild.ts: -------------------------------------------------------------------------------- 1 | import type { Guild, GuildMember, Interaction } from 'discord.js'; 2 | 3 | import { client } from '../client.js'; 4 | import { getConfigProperty } from '../configuration/main.js'; 5 | 6 | export const getGuild = async (interaction?: Interaction) => { 7 | if (interaction?.guild !== null && interaction?.guild !== undefined) { 8 | return interaction.guild; 9 | } 10 | 11 | await client.guilds.fetch(); 12 | 13 | const guildId = getConfigProperty('guild'); 14 | 15 | if (guildId === undefined) { 16 | return null; 17 | } 18 | 19 | const guild = client.guilds.cache.get(guildId); 20 | 21 | return guild ?? null; 22 | }; 23 | 24 | export const getMemberFromGuild = async ( 25 | userId: string, 26 | guild?: Guild | null, 27 | ) => { 28 | const chosenGuild = guild ?? (await getGuild()); 29 | 30 | if (chosenGuild === null) { 31 | return null; 32 | } 33 | 34 | try { 35 | return await chosenGuild.members.fetch(userId); 36 | } catch { 37 | return null; 38 | } 39 | }; 40 | 41 | export const getMembersFromGuild = async ( 42 | userIds: string[], 43 | guild?: Guild | null, 44 | ): Promise => { 45 | const chosenGuild = guild ?? (await getGuild()); 46 | 47 | if (chosenGuild === null) { 48 | return []; 49 | } 50 | 51 | const results = await Promise.all( 52 | userIds.map((userId) => getMemberFromGuild(userId, chosenGuild)), 53 | ); 54 | 55 | return results.filter((member) => member !== null); 56 | }; 57 | 58 | export const getChannelFromGuild = async (channelId: string, guild?: Guild) => { 59 | const guildToUse = guild ?? (await getGuild()); 60 | 61 | try { 62 | return (await guildToUse?.channels.fetch(channelId)) ?? null; 63 | } catch { 64 | return null; 65 | } 66 | }; 67 | -------------------------------------------------------------------------------- /src/utils/levels.ts: -------------------------------------------------------------------------------- 1 | export const IRREGULARS_LEVEL = 15; 2 | export const VIP_LEVEL = 20; 3 | 4 | export const ADMIN_OVERRIDE_LEVEL = 20; 5 | -------------------------------------------------------------------------------- /src/utils/links.ts: -------------------------------------------------------------------------------- 1 | export const getNormalizedUrl = (url: string) => 2 | url.startsWith('http') ? url : `https://${url}`; 3 | -------------------------------------------------------------------------------- /src/utils/members.ts: -------------------------------------------------------------------------------- 1 | import { type GuildMember, PermissionsBitField } from 'discord.js'; 2 | 3 | import { client } from '../client.js'; 4 | import { getRolesProperty } from '../configuration/main.js'; 5 | import { getBarByUserId } from '../data/database/Bar.js'; 6 | import { getExperienceByUserId } from '../data/database/Experience.js'; 7 | import { Role } from '../lib/schemas/Role.js'; 8 | 9 | const memberHasRole = (member: GuildMember, role: Role) => { 10 | const roleId = getRolesProperty(role); 11 | 12 | return roleId !== undefined && member.roles.cache.has(roleId); 13 | }; 14 | 15 | export const getUsername = async (userId: string) => { 16 | const user = await client.users.fetch(userId); 17 | 18 | return user.tag; 19 | }; 20 | 21 | export const excludeMembersWithRole = (members: GuildMember[], role: Role) => { 22 | const roleId = getRolesProperty(role); 23 | 24 | if (roleId === undefined) { 25 | return members; 26 | } 27 | 28 | return members.filter((member) => !member.roles.cache.has(roleId)); 29 | }; 30 | 31 | export const isMemberAdministrator = (member: GuildMember) => 32 | member.permissions.has(PermissionsBitField.Flags.Administrator); 33 | 34 | export const isMemberInVip = (member: GuildMember) => { 35 | if (isMemberAdministrator(member)) { 36 | return true; 37 | } 38 | 39 | return ( 40 | memberHasRole(member, Role.VIP) || 41 | memberHasRole(member, Role.Moderators) || 42 | memberHasRole(member, Role.Administrators) 43 | ); 44 | }; 45 | 46 | export const isMemberInManagement = (member: GuildMember) => 47 | memberHasRole(member, Role.Management); 48 | 49 | export const isMemberInIrregulars = (member: GuildMember) => 50 | memberHasRole(member, Role.Irregulars); 51 | 52 | export const isMemberInRegulars = (member: GuildMember) => 53 | memberHasRole(member, Role.Regulars); 54 | 55 | export const isMemberAdmin = (member: GuildMember) => { 56 | if (isMemberAdministrator(member)) { 57 | return true; 58 | } 59 | 60 | return ( 61 | memberHasRole(member, Role.Administrators) || 62 | memberHasRole(member, Role.Moderators) 63 | ); 64 | }; 65 | 66 | export const isMemberLevel = async ( 67 | member: GuildMember, 68 | level: number, 69 | orAbove = true, 70 | ) => { 71 | const experience = await getExperienceByUserId(member.id); 72 | 73 | if (experience === null) { 74 | return false; 75 | } 76 | 77 | return orAbove ? experience.level >= level : experience.level === level; 78 | }; 79 | 80 | export const isMemberBarred = async (userId: string) => { 81 | const bar = await getBarByUserId(userId); 82 | 83 | return bar !== null; 84 | }; 85 | 86 | export const excludeBarredMembers = async (userIds: string[]) => { 87 | const nonBarredMembers = await Promise.all( 88 | userIds.map(async (userId) => 89 | (await isMemberBarred(userId)) ? null : userId, 90 | ), 91 | ); 92 | 93 | return nonBarredMembers.filter((userId) => userId !== null); 94 | }; 95 | -------------------------------------------------------------------------------- /src/utils/options.ts: -------------------------------------------------------------------------------- 1 | const transformations = { 2 | а: ['a'], 3 | А: ['A'], 4 | б: ['b'], 5 | Б: ['B'], 6 | в: ['v'], 7 | В: ['V'], 8 | г: ['g'], 9 | Г: ['G'], 10 | ѓ: ['g', 'gj'], 11 | Ѓ: ['G', 'GJ'], 12 | д: ['d'], 13 | Д: ['D'], 14 | е: ['e'], 15 | Е: ['E'], 16 | ж: ['z', 'zh'], 17 | Ж: ['Z', 'ZH'], 18 | з: ['z'], 19 | З: ['Z'], 20 | ѕ: ['z', 'dz'], 21 | Ѕ: ['Z', 'DZ'], 22 | и: ['i'], 23 | И: ['I'], 24 | ј: ['j'], 25 | Ј: ['J'], 26 | к: ['k'], 27 | К: ['K'], 28 | ќ: ['k', 'kj'], 29 | Ќ: ['K', 'KJ'], 30 | л: ['l'], 31 | Л: ['L'], 32 | љ: ['l', 'lj'], 33 | Љ: ['L', 'LJ'], 34 | м: ['m'], 35 | М: ['M'], 36 | н: ['n'], 37 | Н: ['N'], 38 | њ: ['n', 'nj'], 39 | Њ: ['N', 'NJ'], 40 | о: ['o'], 41 | О: ['O'], 42 | п: ['p'], 43 | П: ['P'], 44 | р: ['r'], 45 | Р: ['R'], 46 | с: ['s'], 47 | С: ['S'], 48 | т: ['t'], 49 | Т: ['T'], 50 | у: ['u'], 51 | У: ['U'], 52 | ф: ['f'], 53 | Ф: ['F'], 54 | х: ['h'], 55 | Х: ['H'], 56 | ц: ['c'], 57 | Ц: ['C'], 58 | ч: ['c', 'ch'], 59 | Ч: ['C', 'CH'], 60 | џ: ['d', 'dz', 'dzh', 'dj'], 61 | Џ: ['D', 'DZ', 'DZH', 'DJ'], 62 | ш: ['s', 'sh'], 63 | Ш: ['S', 'SH'], 64 | }; 65 | 66 | const transform = (word: string) => { 67 | let suffixes: string[] = []; 68 | 69 | suffixes = word.length === 1 ? [''] : transform(word.slice(1)); 70 | 71 | const transformed: string[] = []; 72 | 73 | // @ts-expect-error even if this is undefined, nullish coalescing works just fine 74 | for (const letter of transformations[word[0]] ?? word[0]) { 75 | for (const suffix of suffixes) { 76 | transformed.push(letter + suffix); 77 | } 78 | } 79 | 80 | return transformed; 81 | }; 82 | 83 | export const transformOptions = (options: string[]) => { 84 | const results: Record = {}; 85 | 86 | for (const option of options) { 87 | for (const transformedOption of transform(option)) { 88 | results[transformedOption] = option; 89 | } 90 | 91 | results[option] = option; 92 | } 93 | 94 | return results; 95 | }; 96 | -------------------------------------------------------------------------------- /src/utils/polls/actions/lottery.ts: -------------------------------------------------------------------------------- 1 | import type { GuildMember, Poll } from 'discord.js'; 2 | 3 | import { getRolesProperty } from '../../../configuration/main.js'; 4 | import { Channel } from '../../../lib/schemas/Channel.js'; 5 | import { LotteryPollType } from '../../../lib/schemas/PollType.js'; 6 | import { Role } from '../../../lib/schemas/Role.js'; 7 | import { logger } from '../../../logger.js'; 8 | import { commandErrorFunctions } from '../../../translations/commands.js'; 9 | import { labels } from '../../../translations/labels.js'; 10 | import { logErrorFunctions } from '../../../translations/logs.js'; 11 | import { specialStringFunctions } from '../../../translations/special.js'; 12 | import { getChannel } from '../../channels.js'; 13 | import { getMembersFromGuild } from '../../guild.js'; 14 | import { excludeBarredMembers, excludeMembersWithRole } from '../../members.js'; 15 | import { 16 | getLotteryPollInformation, 17 | selectRandomWinners, 18 | selectRandomWinnersWeighted, 19 | } from '../core/lottery.js'; 20 | import { getVoters } from '../utils.js'; 21 | 22 | const executeRegularsLotteryPoll = async ( 23 | members: GuildMember[], 24 | weighted: boolean, 25 | winnerCount: number, 26 | ) => { 27 | const regularsRoleId = getRolesProperty(Role.Regulars); 28 | const regularsChannel = getChannel(Channel.Regulars); 29 | 30 | if (regularsRoleId === undefined) { 31 | await regularsChannel?.send( 32 | commandErrorFunctions.roleNotFound(Role.Regulars), 33 | ); 34 | logger.warn(logErrorFunctions.roleNotFound(Role.Regulars)); 35 | 36 | return; 37 | } 38 | 39 | const filteredMembers = excludeMembersWithRole(members, Role.Regulars); 40 | 41 | if (filteredMembers.length === 0) { 42 | return; 43 | } 44 | 45 | const winnerMembers = weighted 46 | ? await selectRandomWinnersWeighted(filteredMembers, winnerCount) 47 | : selectRandomWinners(filteredMembers, winnerCount); 48 | 49 | for (const member of winnerMembers) { 50 | await member.roles.add(regularsRoleId); 51 | } 52 | 53 | await regularsChannel?.send( 54 | specialStringFunctions.regularsWelcome( 55 | winnerMembers.map((member) => member.user.id), 56 | ), 57 | ); 58 | }; 59 | 60 | const LOTTERY_POLL_OPTIONS: Record< 61 | LotteryPollType, 62 | ( 63 | members: GuildMember[], 64 | weighted: boolean, 65 | winnerCount: number, 66 | ) => Promise 67 | > = { 68 | [LotteryPollType.REGULARS_LOTTERY]: executeRegularsLotteryPoll, 69 | }; 70 | 71 | export const executeLotteryPollAction = async (poll: Poll) => { 72 | const { pollType, weighted, winnerCount } = getLotteryPollInformation( 73 | poll.message.content, 74 | ); 75 | 76 | if (pollType === null || winnerCount === null) { 77 | logger.warn( 78 | logErrorFunctions.lotteryPollNotExecutedError( 79 | pollType ?? labels.unknown, 80 | winnerCount ?? labels.unknown, 81 | ), 82 | ); 83 | 84 | return; 85 | } 86 | 87 | const participantMembers = await getVoters(poll) 88 | .then(excludeBarredMembers) 89 | .then((userIds) => getMembersFromGuild(userIds, poll.message.guild)); 90 | 91 | await LOTTERY_POLL_OPTIONS[pollType]( 92 | participantMembers, 93 | weighted, 94 | winnerCount, 95 | ); 96 | }; 97 | -------------------------------------------------------------------------------- /src/utils/polls/constants.ts: -------------------------------------------------------------------------------- 1 | import { PollCategory } from '../../lib/schemas/PollCategory.js'; 2 | import { 3 | LotteryPollType, 4 | type PollType, 5 | SpecialPollType, 6 | } from '../../lib/schemas/PollType.js'; 7 | import { labels } from '../../translations/labels.js'; 8 | 9 | export const POLL_CATEGORIES: Record = { 10 | ...(Object.fromEntries( 11 | Object.values(LotteryPollType).map((type: LotteryPollType) => [ 12 | type, 13 | PollCategory.LOTTERY, 14 | ]), 15 | ) as Record), 16 | 17 | ...(Object.fromEntries( 18 | Object.values(SpecialPollType).map((type: SpecialPollType) => [ 19 | type, 20 | PollCategory.SPECIAL, 21 | ]), 22 | ) as Record), 23 | } as const; 24 | 25 | export const SPECIAL_POLL_OPTIONS = [ 26 | labels.yes, 27 | labels.no, 28 | labels.abstain, 29 | ] as const; 30 | -------------------------------------------------------------------------------- /src/utils/polls/main.ts: -------------------------------------------------------------------------------- 1 | import { type Poll } from 'discord.js'; 2 | 3 | import { PollCategory } from '../../lib/schemas/PollCategory.js'; 4 | import { PollTypeSchema } from '../../lib/schemas/PollType.js'; 5 | import { POLL_CATEGORIES } from './constants.js'; 6 | import { decideLotteryPoll } from './core/lottery.js'; 7 | import { decideSpecialPoll } from './core/special.js'; 8 | import { getPollArguments } from './utils.js'; 9 | 10 | export const handlePoll = async (poll: Poll) => { 11 | const [pollType] = getPollArguments(poll.message.content); 12 | 13 | if (pollType === undefined) { 14 | return; 15 | } 16 | 17 | const parsedPollType = PollTypeSchema.safeParse(pollType); 18 | if (!parsedPollType.success) { 19 | return; 20 | } 21 | 22 | switch (POLL_CATEGORIES[parsedPollType.data]) { 23 | case PollCategory.LOTTERY: 24 | if (!poll.resultsFinalized) { 25 | return; 26 | } 27 | 28 | await decideLotteryPoll(poll); 29 | break; 30 | 31 | case PollCategory.SPECIAL: 32 | await decideSpecialPoll(poll); 33 | break; 34 | 35 | default: 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /src/utils/polls/utils.ts: -------------------------------------------------------------------------------- 1 | import type { Poll } from 'discord.js'; 2 | 3 | import { POLL_IDENTIFIER_REGEX } from '../regex.js'; 4 | 5 | export const getPollArguments = (text: string) => { 6 | const match = POLL_IDENTIFIER_REGEX.exec(text); 7 | 8 | if (match?.groups?.['content'] === undefined) { 9 | return []; 10 | } 11 | 12 | return match.groups['content'].split('-'); 13 | }; 14 | 15 | export const getPollContent = (title: string, identifier: string) => 16 | `${title}\n-# ${identifier}`; 17 | 18 | export const getVoters = async (poll: Poll) => { 19 | const votes = await Promise.all( 20 | poll.answers.map(async (answer) => await answer.voters.fetch()), 21 | ); 22 | const voters = votes 23 | .flatMap((vote) => vote.values().toArray()) 24 | .map((voter) => voter.id); 25 | 26 | return voters; 27 | }; 28 | -------------------------------------------------------------------------------- /src/utils/process.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | 3 | import { Channel } from '../lib/schemas/Channel.js'; 4 | import { logger } from '../logger.js'; 5 | import { exitMessageFunctions, exitMessages } from '../translations/logs.js'; 6 | import { getChannel } from './channels.js'; 7 | 8 | const shutdown = async () => { 9 | logger.info(exitMessages.shutdownGracefully); 10 | 11 | const prisma = new PrismaClient(); 12 | try { 13 | await prisma.$disconnect(); 14 | logger.info(exitMessages.databaseConnectionClosed); 15 | } catch (error) { 16 | logger.error(exitMessageFunctions.databaseConnectionError(error)); 17 | } 18 | 19 | // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit 20 | process.exit(0); 21 | }; 22 | 23 | const logErrorToChannel = async (thrownError?: Error) => { 24 | const logsChannel = getChannel(Channel.Logs); 25 | try { 26 | await (thrownError 27 | ? logsChannel?.send( 28 | exitMessageFunctions.shutdownWithError(thrownError.message), 29 | ) 30 | : logsChannel?.send(exitMessages.shutdownGracefully)); 31 | } catch { 32 | // Do nothing 33 | } 34 | }; 35 | 36 | export const attachProcessListeners = () => { 37 | process.on('SIGINT', () => shutdown()); 38 | 39 | process.on('SIGTERM', () => shutdown()); 40 | 41 | process.on('uncaughtException', async (error) => { 42 | await logErrorToChannel(error); 43 | }); 44 | 45 | process.on('warning', (warning) => { 46 | logger.warn(warning); 47 | }); 48 | 49 | process.on('beforeExit', async () => { 50 | logger.info(exitMessages.beforeExit); 51 | const prisma = new PrismaClient(); 52 | await prisma.$disconnect(); 53 | }); 54 | 55 | process.on('exit', () => { 56 | logger.info(exitMessages.goodbye); 57 | }); 58 | }; 59 | -------------------------------------------------------------------------------- /src/utils/regex.ts: -------------------------------------------------------------------------------- 1 | export const LINK_REGEX = /https?:\/\/\S+\.\S+/u; 2 | export const USER_ID_REGEX = /(?<=<@)\d+(?=>)/u; 3 | export const EMOJI_REGEX = /<:\w+:\d+>/gu; 4 | export const URL_REGEX = /\bhttps?:\/\/\S+/gu; 5 | export const POLL_IDENTIFIER_REGEX = /\[(?[\w-]+)\]/u; 6 | -------------------------------------------------------------------------------- /src/utils/reminders.ts: -------------------------------------------------------------------------------- 1 | import { type Reminder } from '@prisma/client'; 2 | import { userMention } from 'discord.js'; 3 | 4 | import { client } from '../client.js'; 5 | import { deleteReminder, getReminders } from '../data/database/Reminder.js'; 6 | import { logger } from '../logger.js'; 7 | import { labels } from '../translations/labels.js'; 8 | import { logErrorFunctions } from '../translations/logs.js'; 9 | 10 | const remindUser = async (reminder: Reminder) => { 11 | if (reminder.privateMessage) { 12 | const user = await client.users.fetch(reminder.userId); 13 | 14 | await user.send(`${labels.reminder}: ${reminder.description}`); 15 | 16 | return; 17 | } 18 | 19 | if (reminder.channelId === null) { 20 | return; 21 | } 22 | 23 | const channel = await client.channels.fetch(reminder.channelId); 24 | 25 | if (!channel?.isSendable()) { 26 | return; 27 | } 28 | 29 | await channel.send({ 30 | allowedMentions: { 31 | parse: ['users'], 32 | }, 33 | content: `${userMention(reminder.userId)} ${labels.reminder}: ${ 34 | reminder.description 35 | }`, 36 | }); 37 | }; 38 | 39 | export const sendReminders = async () => { 40 | try { 41 | const reminders = await getReminders(); 42 | 43 | if (reminders === null) { 44 | return; 45 | } 46 | 47 | for (const reminder of reminders) { 48 | if (reminder.timestamp.getTime() <= Date.now()) { 49 | await remindUser(reminder); 50 | await deleteReminder(reminder.id); 51 | } 52 | } 53 | } catch (error) { 54 | logger.error(logErrorFunctions.reminderLoadError(error)); 55 | } 56 | }; 57 | -------------------------------------------------------------------------------- /src/utils/tickets.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type AnyThreadChannel, 3 | type ButtonInteraction, 4 | ChannelType, 5 | type ChatInputCommandInteraction, 6 | roleMention, 7 | ThreadAutoArchiveDuration, 8 | } from 'discord.js'; 9 | 10 | import { getTicketCloseComponents } from '../components/tickets.js'; 11 | import { 12 | getChannelsProperty, 13 | getTicketingProperty, 14 | } from '../configuration/main.js'; 15 | import { Channel } from '../lib/schemas/Channel.js'; 16 | import { type Ticket } from '../lib/schemas/Ticket.js'; 17 | import { logger } from '../logger.js'; 18 | import { logMessageFunctions } from '../translations/logs.js'; 19 | import { 20 | ticketMessageFunctions, 21 | ticketMessages, 22 | } from '../translations/tickets.js'; 23 | import { getChannel } from './channels.js'; 24 | import { getGuild } from './guild.js'; 25 | 26 | export const getActiveTickets = async ( 27 | interaction?: ChatInputCommandInteraction, 28 | ) => { 29 | const guild = await getGuild(interaction); 30 | const ticketsChannelId = getChannelsProperty(Channel.Tickets); 31 | 32 | const threads = guild?.channels.cache.filter( 33 | (channel): channel is AnyThreadChannel => 34 | channel.isThread() && 35 | channel.parentId === ticketsChannelId && 36 | !channel.archived && 37 | !channel.locked, 38 | ); 39 | 40 | return threads; 41 | }; 42 | 43 | export const createTicket = async ( 44 | interaction: ButtonInteraction | ChatInputCommandInteraction, 45 | ticketMetadata: Ticket, 46 | ) => { 47 | const ticketsChannel = getChannel(Channel.Tickets); 48 | 49 | if ( 50 | ticketsChannel === undefined || 51 | ticketsChannel.type !== ChannelType.GuildText 52 | ) { 53 | return; 54 | } 55 | 56 | const ticketChannel = await ticketsChannel.threads.create({ 57 | autoArchiveDuration: ThreadAutoArchiveDuration.OneWeek, 58 | invitable: false, 59 | name: `${interaction.user.tag} - ${ticketMetadata.name}`, 60 | type: ChannelType.PrivateThread, 61 | }); 62 | 63 | await ticketChannel.send( 64 | ticketMessageFunctions.ticketCreated(interaction.user.id), 65 | ); 66 | 67 | const components = getTicketCloseComponents(ticketChannel.id); 68 | await ticketChannel.send({ 69 | components, 70 | content: ticketMessages.sendMessage, 71 | }); 72 | 73 | await interaction.editReply( 74 | ticketMessageFunctions.ticketLink(ticketChannel.url), 75 | ); 76 | 77 | const collector = ticketChannel.createMessageCollector({ 78 | time: 1_800_000, 79 | }); 80 | 81 | collector.once('collect', async () => { 82 | await ticketChannel.send( 83 | ticketMessageFunctions.ticketStarted( 84 | ticketMetadata.roles.map((role) => roleMention(role)).join(' '), 85 | ), 86 | ); 87 | 88 | collector.stop(); 89 | }); 90 | 91 | collector.on('end', async (messages) => { 92 | if (messages.size > 0) { 93 | return; 94 | } 95 | 96 | await ticketChannel.delete(); 97 | }); 98 | }; 99 | 100 | export const closeTicket = async (ticketId: string) => { 101 | const ticketsChannel = getChannel(Channel.Tickets); 102 | 103 | if ( 104 | ticketsChannel === undefined || 105 | ticketsChannel.type !== ChannelType.GuildText 106 | ) { 107 | return; 108 | } 109 | 110 | const ticketChannel = ticketsChannel.threads.cache.get(ticketId); 111 | 112 | if (ticketChannel === undefined) { 113 | return; 114 | } 115 | 116 | await ticketChannel.setLocked(true); 117 | await ticketChannel.setArchived(true); 118 | 119 | logger.info(logMessageFunctions.closedTicket(ticketId)); 120 | }; 121 | 122 | export const closeInactiveTickets = async () => { 123 | const allowedInactivityDays = getTicketingProperty('allowedInactivityDays'); 124 | 125 | const maxTicketInactivityMilliseconds = allowedInactivityDays * 86_400_000; 126 | 127 | const ticketThreads = await getActiveTickets(); 128 | 129 | if (ticketThreads === undefined || ticketThreads.size === 0) { 130 | return; 131 | } 132 | 133 | for (const thread of ticketThreads.values()) { 134 | await thread.messages.fetch(); 135 | const lastMessage = thread.lastMessage; 136 | 137 | if (lastMessage === null) { 138 | continue; 139 | } 140 | 141 | const lastMessageDate = lastMessage.createdAt; 142 | 143 | if ( 144 | Date.now() - lastMessageDate.getTime() > 145 | maxTicketInactivityMilliseconds 146 | ) { 147 | await closeTicket(thread.id); 148 | } 149 | } 150 | }; 151 | -------------------------------------------------------------------------------- /src/utils/webhooks.ts: -------------------------------------------------------------------------------- 1 | import { type Guild } from 'discord.js'; 2 | 3 | import { labels } from '../translations/labels.js'; 4 | import { getChannelFromGuild } from './guild.js'; 5 | 6 | export const getOrCreateWebhookByChannelId = async ( 7 | channelId: string, 8 | guild?: Guild, 9 | ) => { 10 | const channel = await getChannelFromGuild(channelId, guild); 11 | 12 | if (channel === null || !channel.isTextBased() || channel.isThread()) { 13 | return null; 14 | } 15 | 16 | const webhooks = await channel.fetchWebhooks(); 17 | const firstWebhook = webhooks.first(); 18 | 19 | if (firstWebhook === undefined) { 20 | return await channel.createWebhook({ 21 | name: labels.starboard, 22 | }); 23 | } 24 | 25 | return firstWebhook; 26 | }; 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowUnreachableCode": false, 4 | "allowUnusedLabels": false, 5 | "checkJs": true, 6 | "emitDecoratorMetadata": true, 7 | "esModuleInterop": true, 8 | "exactOptionalPropertyTypes": true, 9 | "experimentalDecorators": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "lib": [ 12 | "ESNext" 13 | ], 14 | "module": "NodeNext", 15 | "moduleResolution": "NodeNext", 16 | "noFallthroughCasesInSwitch": true, 17 | "noImplicitOverride": true, 18 | "noImplicitReturns": true, 19 | "noPropertyAccessFromIndexSignature": true, 20 | "noUncheckedIndexedAccess": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "outDir": "dist", 24 | "resolveJsonModule": true, 25 | "strict": true, 26 | "target": "ESNext" 27 | }, 28 | "exclude": [ 29 | "eslint.config.js", 30 | "vitest.config.js", 31 | "dist", 32 | "test" 33 | ] 34 | } 35 | --------------------------------------------------------------------------------