├── .eslintrc.js ├── .github ├── CODE-OF-CONDUCT.md ├── CONTRIBUTING.md ├── FUNDING.yml └── workflows │ └── codeql.yml ├── .gitignore ├── .nvmrc ├── ERD.svg ├── LICENSE.md ├── README.md ├── assets └── DTel.jpeg ├── package-lock.json ├── package.json ├── prisma └── schema.prisma ├── src ├── commands │ ├── call │ │ ├── hangup.ts │ │ ├── hold.ts │ │ └── status.ts │ ├── maintainer │ │ ├── addvip.ts │ │ ├── eval.ts │ │ └── stats.ts │ ├── standard │ │ ├── balance.ts │ │ ├── block.ts │ │ ├── call.ts │ │ ├── daily.ts │ │ ├── help.ts │ │ ├── info.ts │ │ ├── invite.ts │ │ ├── links.ts │ │ ├── mailbox clear.ts │ │ ├── mailbox delete.ts │ │ ├── mailbox messages.ts │ │ ├── mailbox settings.ts │ │ ├── mention list.ts │ │ ├── mention remove.ts │ │ ├── mention toggle.ts │ │ ├── pay common.ts │ │ ├── pay id.ts │ │ ├── pay user.ts │ │ ├── ping.ts │ │ ├── rcall.ts │ │ ├── report.ts │ │ ├── strikes.ts │ │ ├── vote.ts │ │ └── wizard.ts │ └── support │ │ ├── addcredit.ts │ │ ├── blacklist.ts │ │ ├── cinfo.ts │ │ ├── deassign.ts │ │ ├── ninfo.ts │ │ ├── reassign.ts │ │ ├── strike add.ts │ │ ├── strike remove.ts │ │ └── uinfo.ts ├── config │ ├── commands.ts │ └── config.ts ├── database │ └── db.ts ├── dtel.ts ├── embed.js ├── events │ ├── allShardsReady.ts │ ├── guildCreate.ts │ ├── guildDelete.ts │ ├── interactionCreate.ts │ ├── messageCreate.ts │ ├── messageDelete.ts │ ├── messageUpdate.ts │ ├── ready.ts │ ├── sharderMessage.ts │ └── typingStart.ts ├── index.ts ├── interactions │ ├── call │ │ ├── 233-open.ts │ │ ├── 233-renew.ts │ │ ├── 411-manage-add-modal.ts │ │ ├── 411-manage-delete-cancel.ts │ │ ├── 411-manage-delete-confirm.ts │ │ ├── 411-manage-edit-modal.ts │ │ ├── 411-manage-selector.ts │ │ ├── 411-search-exit.ts │ │ ├── 411-search-modal-submit.ts │ │ ├── 411-search-next.ts │ │ ├── 411-search-prev.ts │ │ ├── 411-search-search.ts │ │ ├── 411-selector.ts │ │ ├── 411-vip-customname-modal-submit.ts │ │ ├── 411-vip-hide-selector.ts │ │ ├── 411-vip-selector.ts │ │ ├── 411-vip-upgrade-length.ts │ │ ├── hangup.ts │ │ └── pickup.ts │ ├── mailbox │ │ ├── clear │ │ │ └── confirm.ts │ │ ├── delete │ │ │ └── select.ts │ │ ├── messages │ │ │ ├── next.ts │ │ │ └── prev.ts │ │ ├── send │ │ │ ├── initiate.ts │ │ │ └── modal.ts │ │ └── settings │ │ │ └── update.ts │ ├── mention │ │ └── remove │ │ │ └── selector.ts │ └── wizard │ │ ├── modalSubmit.ts │ │ └── ready.ts ├── interfaces │ ├── commandData.ts │ ├── constructable.ts │ └── numbersWithGuilds.ts ├── internals │ ├── 411 │ │ └── vip.ts │ ├── callClient.ts │ ├── client.ts │ ├── commandProcessor.ts │ ├── componentProcessor.ts │ ├── console.ts │ ├── folder.md │ ├── jobs.ts │ ├── modalProcessor.ts │ ├── processor.ts │ └── utils.ts └── internationalization │ ├── data │ └── english.ts │ ├── folder.md │ └── i18n.ts ├── target └── npmlist.json └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | "eslint:recommended", 4 | "plugin:@typescript-eslint/recommended", 5 | ], 6 | parser: "@typescript-eslint/parser", 7 | plugins: [ 8 | "@typescript-eslint", 9 | ], 10 | parserOptions: { 11 | ecmaVersion: 2017, 12 | }, 13 | env: { 14 | es6: true, 15 | node: true, 16 | }, 17 | ignorePatterns: ["build/**", "dist/**", "node_modules/**"], 18 | rules: { 19 | "require-atomic-updates": 0, 20 | "no-compare-neg-zero": "error", 21 | "@typescript-eslint/no-extra-parens": ["warn", "all", { nestedBinaryExpressions: false }], 22 | "@typescript-eslint/no-non-null-assertion": 0, 23 | "@typescript-eslint/no-var-requires": 0, 24 | "no-template-curly-in-string": "error", 25 | "no-unsafe-negation": "error", 26 | "accessor-pairs": "warn", 27 | curly: ["error", "multi-line", "consistent"], 28 | "dot-location": ["error", "property"], 29 | "dot-notation": "error", 30 | "no-empty-function": "error", 31 | "no-floating-decimal": "error", 32 | "no-implied-eval": "error", 33 | "no-invalid-this": "error", 34 | "no-lone-blocks": "error", 35 | "no-multi-spaces": "error", 36 | "no-new-func": "error", 37 | "no-new-wrappers": "error", 38 | "no-new": "error", 39 | "no-octal-escape": "error", 40 | "no-return-assign": "error", 41 | "no-return-await": "error", 42 | "no-self-compare": "error", 43 | "no-sequences": "error", 44 | "no-throw-literal": "error", 45 | "no-unmodified-loop-condition": "error", 46 | "no-useless-call": "error", 47 | "no-useless-concat": "error", 48 | "no-useless-escape": "error", 49 | "no-useless-return": "error", 50 | "no-void": "error", 51 | "no-warning-comments": "warn", 52 | "prefer-promise-reject-errors": "error", 53 | "wrap-iife": "error", 54 | yoda: "error", 55 | "no-label-var": "error", 56 | "no-shadow": "error", 57 | "no-undef-init": "error", 58 | "callback-return": "error", 59 | "handle-callback-err": "error", 60 | "no-mixed-requires": "error", 61 | "no-new-require": "error", 62 | "no-path-concat": "error", 63 | "array-bracket-spacing": "error", 64 | "block-spacing": "error", 65 | "brace-style": ["error", "1tbs", { allowSingleLine: true }], 66 | "comma-dangle": ["error", "always-multiline"], 67 | "comma-spacing": "error", 68 | "comma-style": "error", 69 | "computed-property-spacing": "error", 70 | "consistent-this": ["error", "$this"], 71 | "eol-last": "error", 72 | "func-names": "error", 73 | "func-name-matching": "error", 74 | "func-style": ["error", "declaration", { allowArrowFunctions: true }], 75 | indent: ["error", "tab", { SwitchCase: 1 }], 76 | "key-spacing": "error", 77 | "keyword-spacing": "error", 78 | "max-depth": ["error", 8], 79 | "max-nested-callbacks": ["error", { max: 4 }], 80 | "max-statements-per-line": ["error", { max: 2 }], 81 | "new-cap": "off", 82 | "newline-per-chained-call": ["error", { ignoreChainWithDepth: 5 }], 83 | "no-array-constructor": "error", 84 | "no-lonely-if": "error", 85 | "no-mixed-operators": "error", 86 | "no-multiple-empty-lines": ["error", { max: 2, maxEOF: 1, maxBOF: 0 }], 87 | "no-new-object": "error", 88 | "no-spaced-func": "error", 89 | "no-trailing-spaces": "error", 90 | "no-unneeded-ternary": "error", 91 | "no-whitespace-before-property": "error", 92 | "nonblock-statement-body-position": "error", 93 | "object-curly-spacing": ["error", "always"], 94 | "operator-assignment": "error", 95 | "operator-linebreak": ["error", "after"], 96 | "padded-blocks": ["error", "never"], 97 | "quote-props": ["error", "as-needed"], 98 | quotes: ["error", "double", { avoidEscape: true, allowTemplateLiterals: true }], 99 | "semi-spacing": "error", 100 | "@typescript-eslint/semi": "error", 101 | "space-before-blocks": "error", 102 | "space-before-function-paren": ["error", "never"], 103 | "space-in-parens": "error", 104 | "space-infix-ops": "error", 105 | "space-unary-ops": "error", 106 | "spaced-comment": "error", 107 | "template-tag-spacing": "error", 108 | "unicode-bom": "error", 109 | "arrow-body-style": "error", 110 | "arrow-parens": ["error", "as-needed"], 111 | "arrow-spacing": "error", 112 | "no-duplicate-imports": "error", 113 | "no-useless-computed-key": "error", 114 | "no-useless-constructor": "error", 115 | "prefer-arrow-callback": "error", 116 | "prefer-numeric-literals": "error", 117 | "prefer-rest-params": "error", 118 | "prefer-spread": "error", 119 | "prefer-template": "error", 120 | "rest-spread-spacing": "error", 121 | "template-curly-spacing": "error", 122 | "no-console": 0, 123 | "no-irregular-whitespace": ["error", { skipStrings: true, skipComments: true, skipTemplates: true }], 124 | "no-unused-vars": 0, 125 | }, 126 | }; 127 | -------------------------------------------------------------------------------- /.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 contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at austinhuang0131@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | Our commit history is a great example of why some structure when contributing is necessary. 3 | This document describes how you should go about contributing to the repo, please read all relevant parts before contributing. 4 | 5 | ## Feature branches 6 | As both master and development are protected, any changes will have to be made through a feature branch. A feature branch should have the following characteristics through its lifetime: 7 | 8 | - The branch is linked to at least one issue. 9 | - It should only have one clear and achievable goal. 10 | eg. "Add cinfo command" and not "Change license and fix the call command" 11 | - A feature branch should be named after the issue like: `-`. 12 | eg. you have an issue nr 301 named "Add feature branches to contributing", then your branch should be named: `301-add-feature-branches-to-contributing`. 13 | 14 | ## Issues 15 | ### Creation 16 | - Make sure there is not already an issue regarding your topic. 17 | - Keep the title short, descriptive and [imperative]. 18 | - Answer the following questions in the description: 19 | - What is the current situation? 20 | - Why should this be changed? 21 | - What changes are necessary? 22 | - Add any relevant tags. 23 | - If you are planning to resolve the issue yourself, at least partly, then assign yourself to the issue. 24 | 25 | ### Commenting 26 | - All comments should be written in English. 27 | - Be sure to follow our [Code of Conduct]. 28 | - Keep any discussion on-topic, or move it to another issue/platform. eg. our [testing server][invite-test] 29 | 30 | ## Pull requests 31 | 32 | ### Creation 33 | - Keep the title short, descriptive and [imperative]. 34 | - Write a short description of your changes. 35 | If your PR solves any issue(s), make sure to add `resolves #` at the end. 36 | - Add any relevant tags. (if you can) 37 | - When your PR is ready for review, add DTel-HQ/dtel-maintainers as reviewers and wait on feedback. 38 | Do not ask for review in any other way, excluding DTel-HQ members. 39 | 40 | ### Review 41 | These steps have been written to go from quick and easy to more rigorous. 42 | If at any stage you find an issue, you can choose to stop at that stage until a fix has been pushed. Do make sure to go through the entire process of the stage you found an issue at. 43 | 44 | 1. Check whether all automated reviews/actions have passed. 45 | 2. Open up the changes in your code editor and whether there are TS and/or Eslint errors. 46 | 3. Manually review the changes for any obvious mistakes or bad practices. 47 | 4. Run the changes locally and test any relevant parts of the bot. 48 | 49 | ## Commits 50 | - Commits should follow the style made popular by [Angular][Angular contributing]. 51 | - The scope can be any folder, command. It may also be omitted, but preferably not. 52 | 53 | 54 | 55 | [imperative]: https://examples.yourdictionary.com/imperative-sentence-examples.html 56 | 57 | 58 | [Code of Conduct]: ./CODE-OF-CONDUCT.md 59 | 60 | 61 | [Angular contributing]: https://github.com/angular/angular/blob/master/CONTRIBUTING.md#commit 62 | [invite-test]: https://discord.gg/uWQfxdXtFY 63 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: DTel 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: https://dtel.austinhuang.me/en/latest/VIP-Numbers/ 13 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "development" ] 6 | pull_request: 7 | branches: [ "development" ] 8 | schedule: 9 | - cron: "43 23 * * 6" 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [ javascript ] 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v3 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v2 31 | with: 32 | languages: ${{ matrix.language }} 33 | queries: +security-and-quality 34 | 35 | - name: Autobuild 36 | uses: github/codeql-action/autobuild@v2 37 | 38 | - name: Perform CodeQL Analysis 39 | uses: github/codeql-action/analyze@v2 40 | with: 41 | category: "/language:${{ matrix.language }}" 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | DB/ 3 | Logs/ 4 | configuration/auth.js 5 | rethinkdb.exe 6 | dist/ 7 | built/ 8 | build/ 9 | 10 | src/config/auth-*.ts 11 | src/config/auth.ts 12 | src/config/config-*.ts 13 | 14 | .env 15 | 16 | .vscode/ 17 | 18 | Git-Exclude/ 19 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18 -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # DTel License (revision 3) 2 | 3 | *The owners of DTel reserve the right to change the license without prior warning.* 4 | 5 | **LICENSE REVISION 3, written 12th May 2021** 6 | 7 | Bot created by Austin Huang 8 | 9 | Original license written by Austin Huang; revision 1 written by Tempestius; revision 2 written by SunburntRock89; revison 3 written by Rexogamer. 10 | 11 | **The sole owner of DTel is Austin Huang, thereinafter referred to as "the Owner".** 12 | 13 | 14 | ## Open-source Usage 15 | 16 | 17 | DTel, thereinafter referred to as "the Bot", is bound to a restricted open-source. 18 | Users, thereinafter referred to as "the User", may obtain and edit the Bot's code providing 19 | the User's conduct follows the restrictions listed below. 20 | 21 | 1. The User is able to copy, modify, publish, use, or compile the Bot and its code 22 | either in its original source code or as a compiled binary, providing the purpose of 23 | obtaining the modified or original code remains for private and non-commercial use only. 24 | 2. The User is unable to publicly redistribute or sell the Bot or its code without 25 | explicit permission from the sole Owner of the Bot. The Owner reserves the right to 26 | revoke any or all of the permissions listed earlier should the User with these 27 | permissions use the permissions in a way deemed unacceptable by the Owner. 28 | 3. The Bot and/or its code may not be rebranded under a name other than DTel for 29 | public use providing the permissions in Restriction 2 have been given exclusively by the 30 | Owner, unless the Owner has given explicit permission to publicly rehost the Bot under a 31 | different name. 32 | 33 | 34 | ## Private Hosting 35 | 36 | The Bot may only be hosted privately for purposes deemed acceptable by the Owner. 37 | 38 | ## Disclaimer 39 | 40 | THE BOT AND ITS CODE IS PROVIDED ONLY AS IT IS, 41 | NOT INCLUDING A WARRANTY OF ANY KIND, EXPRESSED OR IMPLIED, 42 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 43 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO 44 | EVENT SHALL THE AUTHORS OF THE CODE OR LICENSE BE LIABLE FOR 45 | ANY CLAIMS, DAMAGES OR OTHER LIABILITIES, WHETHER IN AN ACTION OF CONTRACT, 46 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE BOT OR THE USE OR 47 | OTHER DEALINGS IN THE BOT. 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DTel 2 | 3 | 4 | ![version-master] ![version-development] 5 | 6 | [![bot-shield]][invite-bot] [![website-shield]][website] [![gitlocalize-shield]][gitlocalize] [![codacy-shield]][codacy] 7 | 8 | ## What is DTel? 9 | DTel is a Discord bot that allows you to communicate with other servers on Discord. The bot acts like a telephone network, but **cannot** make real-world calls. (sorry about that) 10 | 11 | So, ready to dive into the world of DTel? 12 | 13 | - Read our [documentation][website]! 14 | 15 | - Want the bot for yourself? **Invite it** by clicking here! [![invite-bot-shield]][invite-bot] 16 | 17 | - Join the **DTel Headquarters server** to win credits, chat, get support and call other numbers at ease by clicking here! [![discord][invite-support-shield]][invite-support] 18 | 19 | ## Contributing 20 | There are a lot of ways to contribute to our bot, not just through code. 21 | The main way, and probably easiest, is by [inviting][invite] the bot to your server(s) and using it. Though, there are many other ways: 22 | 23 | ### Development 24 | You can contribute to the development of DTel in multiple ways: 25 | 26 | - Contributing on GitHub 27 | First read our [contributing guidelines][contributing]. Then you are ready to make issues with feature requests or bug reports. Or even resolve issues by forking this repository and making Pull Requests. 28 | 29 | - Testing new features 30 | Join our testing server to play around with the newest features soon to come! [![Discord][invite-test-shield]][invite-test]. 31 | 32 | - Writing translations 33 | Are you fluent in both English and another language? Then you can help us make the bot accessible to more people! You can start translating the bot on [GitLocalize]. 34 | 35 | ### Financially 36 | All of us at DTel have a passion for programming, which is why we were able to create and maintain DTel over the years. 37 | However, hosting the bot isn't free. So if you like what we're doing, have a look at our VIP options by clicking here: [![vip-shield]][website-vip] 38 | 39 | ### Branches 40 | - `development` contains the latest code - this has been reviewed, but there's no guarantee it works. 41 | - `master` contains the code currently used on the bot - this branch is only pushed to when `development`'s code is ready. 42 | - If you'd like to see older code (v1, v2 and the original v4), check out our [archive repo][archive-repo]. 43 | - Any other branches are temporary. 44 | 45 | 46 | [invite-bot]: https://discordapp.com/oauth2/authorize?client_id=377609965554237453&scope=bot&permissions=125953 47 | [invite-test]: https://discord.gg/uWQfxdXtFY 48 | [invite-support]: https://discord.gg/RN7pxrB 49 | [server]: https://discord.gg/DcayXMc 50 | [website]: https://dtel.austinhuang.me/ 51 | [website-vip]: https://dtel.austinhuang.me/en/latest/VIP-Numbers/ 52 | [gitlocalize]: https://gitlocalize.com/repo/3993 53 | [codacy]: https://www.codacy.com/gh/DTel-HQ/dtel/dashboard 54 | [archive-repo]: https://github.com/DTel-HQ/dtel-archive 55 | 56 | 57 | [contributing]: ./.github/CONTRIBUTING.md 58 | 59 | 60 | [invite-bot-shield]: https://img.shields.io/badge/Discord-Get_The_Bot-5865F2.svg 61 | [invite-support-shield]: https://img.shields.io/badge/Discord-Support_Server-5865F2.svg 62 | [invite-test-shield]: https://img.shields.io/badge/Discord-Test_Server-5865F2.svg 63 | [website-shield]: https://readthedocs.org/projects/dtel/badge/?version=latest 64 | [gitlocalize-shield]: https://gitlocalize.com/repo/3993/whole_project/badge.svg 65 | [codacy-shield]: https://app.codacy.com/project/badge/Grade/04d77bc4c8a44d869bfef5967030e249 66 | [vip-shield]: https://img.shields.io/badge/support_us-VIP-green 67 | [bot-shield]: https://discordbots.org/api/widget/status/377609965554237453.png 68 | 69 | [version-master]: https://img.shields.io/github/package-json/v/DTel-HQ/dtel/master 70 | [version-development]: https://img.shields.io/github/package-json/v/DTel-HQ/dtel/development 71 | -------------------------------------------------------------------------------- /assets/DTel.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DTel-HQ/dtel/eef990e3e95e6e67d17f672d64249d2bb77c2c33/assets/DTel.jpeg -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dtel", 3 | "version": "4.0.0", 4 | "description": "A telephone bot for Discord", 5 | "main": "build/index.js", 6 | "scripts": { 7 | "test": "echo \"Error: we haven't set up testing yet.\" && exit 1", 8 | "postinstall": "prisma generate", 9 | "diagram": "prisma generate erd" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/DTel-HQ/dtel.git" 14 | }, 15 | "keywords": [ 16 | "DTEL" 17 | ], 18 | "contributors": [ 19 | "SunburntRock89 ", 20 | "Rexogamer ", 21 | "Austin Huang (https://austinhuang.me)", 22 | "Mitchell Rademaker (https://mitchells.work)" 23 | ], 24 | "license": "SEE LICENSE IN LICENSE", 25 | "bugs": { 26 | "url": "https://github.com/DTel-HQ/dtel/issues" 27 | }, 28 | "homepage": "https://dtel.austinhuang.me", 29 | "engines": { 30 | "node": ">=18" 31 | }, 32 | "nodemonConfig": { 33 | "delay": 100 34 | }, 35 | "dependencies": { 36 | "@prisma/client": "^4.14.1", 37 | "dayjs": "^1.11.7", 38 | "discord-api-types": "^0.37.38", 39 | "discord.js": "^14.11.0", 40 | "i18next": "^21.10.0", 41 | "node-schedule": "^2.1.1", 42 | "uuid": "^9.0.0", 43 | "winston": "^3.8.2", 44 | "winston-daily-rotate-file": "^4.7.1" 45 | }, 46 | "devDependencies": { 47 | "@tsconfig/node18": "^1.0.1", 48 | "@types/node": "^18.15.11", 49 | "@types/node-schedule": "^2.1.0", 50 | "@types/uuid": "^9.0.1", 51 | "@typescript-eslint/eslint-plugin": "^5.57.1", 52 | "@typescript-eslint/parser": "^5.57.1", 53 | "eslint": "^8.38.0", 54 | "prisma": "^4.14.1", 55 | "prisma-erd-generator": "^1.5.4", 56 | "safe-regex": "^2.1.1", 57 | "typescript": "^5.0.4" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | previewFeatures = ["fullTextIndex"] 4 | } 5 | 6 | datasource db { 7 | provider = "mongodb" 8 | url = env("MONGO_URI") 9 | } 10 | 11 | type numberVIP { 12 | expiry DateTime @default("1970-01-01T00:00:00.000Z") 13 | hidden Boolean @default(false) 14 | name String @default("") 15 | } 16 | 17 | type mailboxMessage { 18 | id String 19 | from String 20 | message String 21 | 22 | sent atAndBy 23 | } 24 | 25 | model Mailbox { 26 | number String @id @map("_id") 27 | 28 | autoreply String @default(value: "Sorry, I am currently unavailable. Please call again later.") 29 | receiving Boolean @default(value: true) 30 | messages mailboxMessage[] 31 | 32 | numberDoc Numbers? 33 | } 34 | 35 | type Contact { 36 | name String 37 | number String 38 | description String 39 | } 40 | 41 | model Numbers { 42 | number String @id @map("_id") 43 | channelID String 44 | guildID String? 45 | userID String? 46 | blocked String[] 47 | contacts Contact[] 48 | expiry DateTime 49 | mentions String[] 50 | vip numberVIP? 51 | waiting Boolean @default(false) 52 | createdAt DateTime @default(now()) 53 | 54 | fka String[] @default([]) 55 | 56 | mailbox Mailbox? @relation(fields: [number], references: [number]) 57 | 58 | outgoingCalls ActiveCalls[] @relation("to") 59 | incomingCalls ActiveCalls[] @relation("from") 60 | 61 | guild GuildConfigs? @relation(fields: guildID, references: id) 62 | phonebook Phonebook? 63 | // promote Promote? 64 | 65 | @@unique(fields: [channelID], name: "channel") 66 | @@index(fields: [guildID], name: "guild") 67 | @@index(fields: [userID], name: "user") 68 | } 69 | 70 | // Calls 71 | type atAndBy { 72 | at DateTime @default(now()) 73 | by String 74 | } 75 | 76 | type onHold { 77 | onHold Boolean 78 | holdingSide String? // channel ID of holding side (maybe change this later) 79 | } 80 | 81 | model CallMessages { 82 | id String @id @default(auto()) @map("_id") @db.ObjectId 83 | callID String 84 | forwardedMessageID String @unique 85 | originalMessageID String @unique 86 | sentAt DateTime 87 | sender String 88 | } 89 | 90 | model ActiveCalls { 91 | id String @id @map("_id") 92 | 93 | toNum String 94 | fromNum String 95 | 96 | to Numbers? @relation(name: "to", fields: [toNum], references: [number]) 97 | from Numbers? @relation(name: "from", fields: [fromNum], references: [number]) 98 | 99 | pickedUp atAndBy? 100 | randomCall Boolean @default(false) 101 | started atAndBy 102 | ended atAndBy? 103 | hold onHold 104 | 105 | @@index(fields: [toNum, fromNum], name: "participants") 106 | } 107 | 108 | model ArchivedCalls { 109 | id String @id @map("_id") 110 | 111 | toNum String 112 | fromNum String 113 | 114 | pickedUp atAndBy? 115 | randomCall Boolean @default(false) 116 | started atAndBy 117 | ended atAndBy 118 | hold onHold 119 | 120 | @@index(fields: [toNum, fromNum], name: "participants") 121 | } 122 | 123 | model GuildConfigs { 124 | id String @id @map("_id") 125 | 126 | whitelisted Boolean @default(false) 127 | locale String @default("en-US") 128 | 129 | numbers Numbers[] 130 | strikes Strikes[] 131 | } 132 | 133 | model Accounts { 134 | id String @id @map("_id") 135 | 136 | balance Float @default(0) 137 | dailyClaimedAt DateTime? 138 | vipMonthsRemaining Int @default(0) 139 | 140 | strikes Strikes[] 141 | Votes Votes? 142 | } 143 | 144 | enum StrikeOffenderType { 145 | USER 146 | GUILD 147 | } 148 | 149 | model Strikes { 150 | id String @id @map("_id") 151 | offender String 152 | reason String 153 | type StrikeOffenderType 154 | 155 | created atAndBy 156 | 157 | account Accounts @relation(fields: [offender], references: [id]) 158 | guildConfig GuildConfigs @relation(fields: [offender], references: [id]) 159 | 160 | @@index(fields: [offender], name: "offender") 161 | } 162 | 163 | model Blacklist { 164 | id String @id @map("_id") 165 | reason String? 166 | } 167 | 168 | model Phonebook { 169 | number String @id @map("_id") 170 | description String 171 | 172 | numberDoc Numbers @relation(fields: [number], references: [number]) 173 | 174 | @@fulltext([description]) 175 | } 176 | 177 | model Promote { 178 | number String @id @map("_id") 179 | 180 | renderableNumber String 181 | 182 | // embed discordEmbed 183 | 184 | // lastEdited atAndBy 185 | // lastPromoted atAndBy 186 | 187 | lastPromoMsgID String? 188 | 189 | // numberDoc Numbers @relation(fields: [number], references: [number]) 190 | } 191 | 192 | model Votes { 193 | userID String @id @map("_id") 194 | count Int @default(0) 195 | 196 | account Accounts @relation(fields: [userID], references: [id]) 197 | } 198 | 199 | type discordEmbed { 200 | title String 201 | description String 202 | fields discordEmbedField[] 203 | } 204 | 205 | type discordEmbedField { 206 | name String 207 | value String 208 | n Int 209 | } 210 | -------------------------------------------------------------------------------- /src/commands/call/hangup.ts: -------------------------------------------------------------------------------- 1 | import Command from "../../internals/commandProcessor"; 2 | 3 | export default class HangUp extends Command { 4 | async run(): Promise { 5 | this.call!.hangup(this.interaction); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/commands/call/hold.ts: -------------------------------------------------------------------------------- 1 | import Command from "../../internals/commandProcessor"; 2 | export default class Hold extends Command { 3 | async run(): Promise { 4 | this.call!.putOnHold(this.interaction); 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/commands/call/status.ts: -------------------------------------------------------------------------------- 1 | import { APIEmbed, EmbedBuilder } from "discord.js"; 2 | import Command from "../../internals/commandProcessor"; 3 | 4 | export default class Status extends Command { 5 | async run(): Promise { 6 | this.interaction.reply({ 7 | embeds: [EmbedBuilder.from({ 8 | color: this.config.colors.info, 9 | ...(this.t("embed", { 10 | messageCount: await this.call!.countMessages(), 11 | timeElapsed: this.call!.timeElapsed, 12 | callID: this.call!.id, 13 | }) as APIEmbed), 14 | }).setTimestamp(new Date())], 15 | ephemeral: true, 16 | }); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/commands/maintainer/addvip.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder, User } from "discord.js"; 2 | import Command from "../../internals/commandProcessor"; 3 | 4 | export default class AddCredit extends Command { 5 | async run(): Promise { 6 | const userID = this.interaction.options.getString("user", true); 7 | 8 | let user: User; 9 | try { 10 | user = await this.client.getUser(userID); 11 | } catch { 12 | this.targetUserNotFound(); 13 | return; 14 | } 15 | 16 | if (userID === this.client.user.id) { 17 | this.interaction.reply({ 18 | ephemeral: true, 19 | embeds: [this.client.errorEmbed("I am *the* VIP, don't forget that!")], 20 | }); 21 | return; 22 | } else if (user.bot) { 23 | this.interaction.reply({ 24 | ephemeral: true, 25 | embeds: [this.client.errorEmbed("Not sure, but I think it'll break stuff", { title: "Probably shouldn't do that." })], 26 | }); 27 | return; 28 | } 29 | 30 | let account = await this.fetchAccount(userID); 31 | const monthsToAdd = this.interaction.options.getInteger("months", true); 32 | 33 | if (monthsToAdd < 0 && (account.vipMonthsRemaining + monthsToAdd) < 0) { 34 | this.interaction.reply({ 35 | ephemeral: true, 36 | embeds: [this.client.errorEmbed("That would make this user a negative VIP!", { title: "Are you insane?", footer: { text: "User does not have enough months to remove this many" } })], 37 | }); 38 | return; 39 | } 40 | 41 | account = await this.db.accounts.update({ 42 | where: { 43 | id: account.id, 44 | }, 45 | data: { 46 | vipMonthsRemaining: account.vipMonthsRemaining + monthsToAdd, 47 | }, 48 | }); 49 | 50 | const monthsAdded = monthsToAdd > 0; 51 | const addedOrRemoved = monthsAdded ? "Added" : "Removed"; 52 | 53 | const embed = EmbedBuilder.from({ 54 | color: this.config.colors.vip, 55 | title: `${addedOrRemoved} VIP months!`, 56 | description: `${addedOrRemoved} ${monthsToAdd} month${monthsToAdd == 1 ? "" : "s"} ${monthsAdded ? "to" : "from"} <@${userID}> (${userID})`, 57 | footer: { 58 | icon_url: this.interaction.user.displayAvatarURL(), 59 | text: `${this.interaction.user.tag} (${this.interaction.user.id})`, 60 | }, 61 | }).setTimestamp(new Date()); 62 | 63 | if (this.interaction.channelId != this.config.supportGuild.channels.management) { 64 | this.client.sendCrossShard({ 65 | embeds: [embed], 66 | }, this.config.supportGuild.channels.management); 67 | } 68 | 69 | this.interaction.reply({ 70 | embeds: [embed], 71 | }); 72 | 73 | user.send({ 74 | embeds: [{ 75 | color: this.config.colors.vip, 76 | title: monthsAdded ? "💸 You're a real VIP!" : "VIP Update", 77 | description: `A support member has ${addedOrRemoved.toLowerCase()} ${Math.abs(monthsToAdd).toLocaleString()} VIP month${monthsToAdd == 1 ? "" : "s"} ${monthsAdded ? "to" : "from"} your account.\nYou now have ${account.vipMonthsRemaining} month${monthsToAdd == 1 ? "" : "s"} remaining.\nCall \`*411\` to spend your VIP months.`, 78 | footer: { 79 | text: "Thank you for supporting DTel!", 80 | }, 81 | }], 82 | }).catch(() => null); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/commands/maintainer/eval.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | /* eslint-disable @typescript-eslint/no-unused-vars */ 3 | import { AttachmentBuilder } from "discord.js"; 4 | import { inspect } from "util"; 5 | import Command from "../../internals/commandProcessor"; 6 | 7 | const escapeRegex = (str: string) => str.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&"); 8 | 9 | export default class Eval extends Command { 10 | async run(): Promise { 11 | const client = this.client; 12 | const config = this.client.config; 13 | const winston = this.client.winston; 14 | 15 | const hrstart = process.hrtime(); 16 | let payload = this.interaction.options.getString("code", true); 17 | try { 18 | if (payload.startsWith("```js") && payload.endsWith("```")) payload = payload.substring(5, payload.length - 3); 19 | 20 | const asyncPayload = (code: string) => `(async () => {\n${!payload.includes("return") ? `return ${code.trim()}` : `${code.trim()}`}\n})()`; 21 | 22 | payload = payload 23 | .replace("this.client.token", "") 24 | .replace(/\.token/g, ""); 25 | 26 | const array = [ 27 | escapeRegex(this.client.token!), 28 | ]; 29 | const regex = new RegExp(array.join("|"), "g"); 30 | let result = await eval(asyncPayload(payload)); 31 | 32 | if (typeof result !== "string") result = inspect(result, false, 2); 33 | result = result.replace(regex, "mfa.Jeff"); 34 | if (result.length <= 1980) { 35 | this.interaction.reply({ 36 | embeds: [{ 37 | color: config.colors.success, 38 | description: `\`\`\`js\n${result}\`\`\``, 39 | footer: { 40 | text: `Execution time: ${process.hrtime(hrstart)[0]}s ${Math.floor(process.hrtime(hrstart)[1] / 1000000)}ms`, 41 | }, 42 | }], 43 | }); 44 | } else { 45 | this.interaction.reply({ 46 | embeds: [{ 47 | color: config.colors.info, 48 | title: `The eval results were too large!`, 49 | description: `As such, I've saved them to a file. Here are the results!`, 50 | }], 51 | files: [ 52 | new AttachmentBuilder(Buffer.from(result)) 53 | .setName("eval-results.txt"), 54 | ], 55 | }); 56 | } 57 | } catch (_err) { 58 | const err = _err as Error; 59 | this.interaction.reply({ 60 | embeds: [{ 61 | color: 0xFF0000, 62 | description: `\`\`\`js\n${err.stack}\`\`\``, 63 | footer: { 64 | text: `Execution time: ${process.hrtime(hrstart)[0]}s ${Math.floor(process.hrtime(hrstart)[1] / 1000000)}ms`, 65 | }, 66 | }], 67 | }); 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/commands/maintainer/stats.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder } from "@discordjs/builders"; 2 | import config from "../../config/config"; 3 | import Command from "../../internals/commandProcessor"; 4 | import { formatBalance } from "../../internals/utils"; 5 | import os from "os"; 6 | 7 | export default class Stats extends Command { 8 | format = formatBalance; 9 | 10 | async run(): Promise { 11 | const DTS = config.dtsEmoji; 12 | 13 | // basic stats 14 | const guildCount = await this.client.shard!.fetchClientValues("guilds.cache.size") 15 | .then(results => (results as number[]).reduce((prev, val) => prev + val, 0) || this.client.guilds.cache.size); 16 | const shardCount = process.env.SHARDS; 17 | 18 | const numberCount = await this.db.numbers.count(); 19 | const yellowCount = await this.db.phonebook.count(); 20 | 21 | const blacklisted = await this.db.blacklist.count(); 22 | const whitelisted = await this.db.guildConfigs.count({ 23 | where: { 24 | whitelisted: true, 25 | }, 26 | }); 27 | 28 | const expNumberCount = await this.db.numbers.count({ 29 | where: { 30 | expiry: { 31 | lt: new Date(), 32 | }, 33 | }, 34 | }); 35 | 36 | const firstOfThisMonth = new Date(); 37 | firstOfThisMonth.setDate(1); 38 | firstOfThisMonth.setHours(0, 0, 0, 0); 39 | 40 | const endOfThisMonth = new Date(); 41 | endOfThisMonth.setMonth(endOfThisMonth.getMonth() + 1); 42 | endOfThisMonth.setDate(0); 43 | endOfThisMonth.setHours(0, 0, 0, 0); 44 | 45 | const monthlyNewNumbers = await this.db.numbers.count({ 46 | where: { 47 | createdAt: { 48 | gte: firstOfThisMonth, 49 | lte: endOfThisMonth, 50 | }, 51 | }, 52 | }); 53 | 54 | const totalBalance = formatBalance((await this.db.accounts.aggregate({ 55 | _sum: { 56 | balance: true, 57 | }, 58 | }))._sum.balance || 0); 59 | 60 | 61 | const totalUsers = await this.db.accounts.count(); 62 | const middleUser = Math.ceil(totalUsers / 2); 63 | 64 | const medianBalance = (await this.db.accounts.findFirst({ 65 | skip: middleUser - 1, 66 | }))?.balance || 0; 67 | 68 | const totalUsersWithBal = await this.db.accounts.count({ 69 | where: { 70 | balance: { 71 | not: 0, 72 | }, 73 | }, 74 | }); 75 | 76 | const middleUserWithBal = Math.ceil(totalUsersWithBal / 2); 77 | const filteredMedian = (await this.db.accounts.findFirst({ 78 | skip: middleUserWithBal - 1, 79 | }))?.balance || 0; 80 | 81 | const top5Users = await this.db.accounts.findMany({ 82 | orderBy: { 83 | balance: "desc", 84 | }, 85 | take: 5, 86 | }); 87 | 88 | const embed = new EmbedBuilder() 89 | .setTitle("DTel Statistics") 90 | .setColor(config.colors.info) 91 | .setAuthor({ 92 | name: this.client.user.tag, 93 | iconURL: this.client.user.displayAvatarURL(), 94 | }) 95 | .addFields([{ 96 | name: "Server", 97 | value: `Shards: ${shardCount}\nRAM usage: ${this.format(process.memoryUsage().heapUsed / 1024 / 1024)}MB\nLoad avgs: ${os.loadavg().map(avg => this.format(avg * 100)).join(" | ")}`, 98 | inline: true, 99 | }, { 100 | name: "Numbers", 101 | value: `Total: ${this.format(numberCount)}\nYellowbook: ${yellowCount}\nExpired: ${expNumberCount}\nMonthly new: ${monthlyNewNumbers}`, 102 | inline: true, 103 | }, { 104 | name: "Guilds", 105 | value: `Total: ${this.format(guildCount)}\nNo number: N/D`, 106 | inline: true, 107 | }, { 108 | name: "Lists", 109 | value: `Blacklisted: ${blacklisted}\nWhitelisted: ${whitelisted}`, 110 | inline: true, 111 | }, { 112 | name: "Economy", 113 | value: `Total: ${DTS}${totalBalance}\nMedian: ${DTS}${this.format(medianBalance)}\nFiltered median: ${DTS}${filteredMedian}`, 114 | inline: true, 115 | }, { 116 | name: "Top Balances", 117 | value: top5Users.map(acc => `${DTS}${this.format(acc.balance)} (<@${acc.id}>)`).join("\n"), 118 | inline: false, 119 | }]) 120 | .setTimestamp(); 121 | 122 | 123 | this.interaction.reply({ embeds: [embed] }); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/commands/standard/balance.ts: -------------------------------------------------------------------------------- 1 | import { Accounts } from "@prisma/client"; 2 | import { APIEmbed, User } from "discord.js"; 3 | import Command from "../../internals/commandProcessor"; 4 | import { formatBalance, getAccount } from "../../internals/utils"; 5 | 6 | export default class Balance extends Command { 7 | async run(): Promise { 8 | const taggedUser = this.interaction.options.getUser("user", false); 9 | const id = this.interaction.options.getString("id", false); 10 | 11 | let accountIDToGet = this.interaction.user.id; 12 | if (taggedUser) { 13 | accountIDToGet = taggedUser.id; 14 | } else if (id) { 15 | accountIDToGet = id; 16 | } 17 | 18 | // Ensure we know of the user -- don't share details about users who have left 19 | let user: User; 20 | try { 21 | user = await this.client.getUser(accountIDToGet); 22 | } catch { 23 | this.noAccount(); 24 | return; 25 | } 26 | 27 | let account: Accounts | null; 28 | 29 | if (accountIDToGet == this.interaction.user.id) account = await this.fetchAccount(); 30 | 31 | account = await getAccount(accountIDToGet); 32 | if (!account) { 33 | this.noAccount(); 34 | return; 35 | } 36 | 37 | this.interaction.reply({ 38 | embeds: [{ 39 | color: this.config.colors.info, 40 | author: { 41 | name: `${user.username}#${user.discriminator}`, 42 | icon_url: user.displayAvatarURL(), 43 | }, 44 | ...(this.t("embed", { 45 | balance: formatBalance(account.balance), 46 | vipMonthsRemaining: account.vipMonthsRemaining, 47 | user: user.username, 48 | 49 | interpolation: { 50 | escapeValue: false, 51 | }, 52 | }) as APIEmbed), 53 | }], 54 | ephemeral: true, 55 | }); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/commands/standard/block.ts: -------------------------------------------------------------------------------- 1 | import Command from "../../internals/commandProcessor"; 2 | import { parseNumber } from "../../internals/utils"; 3 | 4 | export default class Block extends Command { 5 | async run(): Promise { 6 | const rawInput = this.interaction.options.getString("number", true); 7 | const toBlock = parseNumber(rawInput); 8 | 9 | if (toBlock === this.number!.number) { 10 | this.interaction.reply({ 11 | embeds: [this.client.errorEmbed(this.t("cantBlockSelf"))], 12 | ephemeral: true, 13 | }); 14 | return; 15 | } 16 | 17 | if (isNaN(Number(toBlock))) { 18 | this.interaction.reply({ 19 | embeds: [this.client.errorEmbed(this.t("invalidBlockingNumber"))], 20 | ephemeral: true, 21 | }); 22 | return; 23 | } 24 | 25 | const toBlockDoc = await this.db.numbers.findUnique({ 26 | where: { 27 | number: toBlock, 28 | }, 29 | }); 30 | 31 | if (!toBlockDoc) { 32 | this.interaction.reply({ 33 | embeds: [this.client.errorEmbed(this.t("numberDoesntExist"))], 34 | ephemeral: true, 35 | }); 36 | return; 37 | } 38 | 39 | const blockedNumberIndex = this.number!.blocked.findIndex(n => n === toBlock); 40 | 41 | // If this number isn't blocked 42 | if (blockedNumberIndex === -1) { 43 | this.number!.blocked.push(toBlockDoc.number); 44 | // Otherwise 45 | } else { 46 | this.number!.blocked.splice(blockedNumberIndex, 1); 47 | } 48 | 49 | await this.db.numbers.update({ 50 | where: { 51 | number: this.number!.number, 52 | }, 53 | data: { 54 | blocked: this.number!.blocked, 55 | }, 56 | }); 57 | 58 | this.interaction.reply({ 59 | embeds: [{ 60 | color: this.config.colors.success, 61 | ...this.t(blockedNumberIndex === -1 ? "blockedSuccess" : "unblockedSuccess", { 62 | numberOrDisplay: toBlockDoc.vip?.expiry && toBlockDoc.vip?.expiry > new Date() ? toBlockDoc.vip.name : rawInput.toUpperCase(), 63 | }), 64 | }], 65 | }); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/commands/standard/call.ts: -------------------------------------------------------------------------------- 1 | import Command from "../../internals/commandProcessor"; 2 | import CallClient from "../../internals/callClient"; 3 | import { ActionRowBuilder, APIEmbed, BaseMessageOptions, ButtonBuilder, ButtonStyle, ChatInputCommandInteraction, EmbedBuilder, MessageComponentInteraction, StringSelectMenuBuilder, StringSelectMenuOptionBuilder } from "discord.js"; 4 | import { getFixedT } from "i18next"; 5 | import { formatBalance, formatDate, upperFirst } from "../../internals/utils"; 6 | import { client } from "../../dtel"; 7 | import { Numbers } from "@prisma/client"; 8 | import config from "../../config/config"; 9 | 10 | export default class Call extends Command { 11 | async run(): Promise { 12 | // Since we're in here, we can assume that there's no call in progress 13 | switch (this.interaction.options.getString("number", true)) { 14 | case "*233": { 15 | return this.twoThreeThree(); 16 | } 17 | case "*411": { 18 | return this.fourOneOne(); 19 | break; 20 | } 21 | default: { 22 | try { 23 | Call.call(this.interaction, this.interaction.options.getString("number", true), this.number!); 24 | } catch { 25 | // Ignore 26 | } 27 | } 28 | } 29 | } 30 | 31 | // TODO: Remove fromNum and have this be standalone? 32 | static async call(interaction: ChatInputCommandInteraction | MessageComponentInteraction, toNum: string, fromNum: Numbers, random = false, alreadyReplied = false): Promise { 33 | const t = getFixedT(interaction.locale, undefined, `commands.call`); 34 | if (!alreadyReplied) await interaction.deferReply(); 35 | 36 | const callObject = new CallClient(client, { 37 | from: fromNum.number, 38 | 39 | to: toNum, 40 | startedBy: interaction.user.id, 41 | random, 42 | }); 43 | 44 | try { 45 | await callObject.initiate(); 46 | interaction.editReply({ 47 | embeds: [{ 48 | color: config.colors.info, 49 | ...t("initiated", { 50 | number: toNum, 51 | callID: callObject.id, 52 | }) as APIEmbed, 53 | }], 54 | }); 55 | } catch (e) { 56 | // This works as when we error out in CallClient, we return a translation path instead of an error message 57 | // Feel free to change it 58 | if (e instanceof Error) { 59 | if (e.message === "otherSideInCall") { 60 | interaction.editReply({ 61 | embeds: [client.errorEmbed(t("errors.otherSideInCall")!)], 62 | }); 63 | 64 | // interaction.editReply({ 65 | // embeds: [{ 66 | // color: config.colors.info, 67 | // ...t("waitPrompt") as APIEmbed, 68 | // }], 69 | // components: [ 70 | // new ActionRowBuilder() 71 | // .addComponents([ 72 | // new ButtonBuilder({ 73 | // customId: "call-waitAccept", 74 | // label: t("waitAccept")!, 75 | // style: ButtonStyle.Primary, 76 | // emoji: "✅", 77 | // }), 78 | // new ButtonBuilder({ 79 | // customId: "call-waitDeny", 80 | // label: t("waitDeny")!, 81 | // style: ButtonStyle.Secondary, 82 | // emoji: "❌", 83 | // }), 84 | // ]), 85 | // ], 86 | // }); 87 | 88 | // setTimeout(() => { 89 | // interaction.deleteReply().catch(() => null); 90 | // }, 60000); 91 | 92 | // TODO: Deal with call waiting in some way 93 | } 94 | 95 | interaction.editReply({ 96 | embeds: [client.errorEmbed(t(`errors.${e.message}`))], 97 | }); 98 | } else { 99 | interaction.editReply({ 100 | embeds: [client.errorEmbed(t(`errors.unexpected`))], 101 | }); 102 | } 103 | } 104 | return callObject; 105 | } 106 | 107 | async twoThreeThree(): Promise { 108 | const t233 = getFixedT(this.interaction.locale, undefined, "commands.call.twoThreeThree"); 109 | 110 | const isVIP = this.number!.vip?.expiry && this.number!.vip?.expiry > new Date(); 111 | const strikeCount = (await this.db.strikes.aggregate({ 112 | where: { 113 | offender: this.number?.guildID || this.interaction.user.id, 114 | }, 115 | _count: { 116 | _all: true, 117 | }, 118 | }))._count._all; 119 | 120 | const embed = EmbedBuilder.from(this.t("twoThreeThree.baseEmbed", { 121 | canAfford: this.account!.balance > 500 ? "canAfford" : "cantAfford", 122 | }) as APIEmbed); 123 | embed 124 | .setColor(isVIP ? this.config.colors.yellowbook : this.config.colors.info) 125 | .setAuthor({ 126 | name: this.interaction.guild?.name || this.interaction.user.username, 127 | iconURL: this.interaction.guild?.iconURL() || this.interaction.user.displayAvatarURL(), 128 | }); 129 | 130 | embed.addFields([{ 131 | name: this.genericT("number"), 132 | value: this.number!.number, 133 | inline: true, 134 | }, { 135 | name: t233("expiry"), 136 | value: formatDate(this.number!.expiry), 137 | inline: true, 138 | }, { 139 | name: t233("credits"), 140 | value: `${this.config.dtsEmoji} ${formatBalance(this.account!.balance)}`, 141 | inline: true, 142 | }, { 143 | name: t233("isVIP"), 144 | value: upperFirst(this.genericT(isVIP ? "yes" : "no")), 145 | inline: true, 146 | }, { 147 | name: t233("vipExpiry"), 148 | value: isVIP ? formatDate(this.number!.vip!.expiry) : this.genericT("notApplicable"), 149 | inline: true, 150 | }, { 151 | name: t233("vipMonths"), 152 | value: this.account!.vipMonthsRemaining.toString(), 153 | inline: true, 154 | }, { 155 | name: t233("blockedNumbers"), 156 | value: this.number!.blocked.length.toString(), 157 | inline: true, 158 | }, { 159 | name: t233("mentions"), 160 | value: this.number!.mentions.length.toString(), 161 | inline: true, 162 | }, { 163 | name: t233("strikes"), 164 | value: strikeCount.toString(), 165 | inline: true, 166 | }]); 167 | 168 | const actionRow = new ActionRowBuilder() 169 | .addComponents( 170 | new ButtonBuilder() 171 | .setStyle(ButtonStyle.Primary) 172 | .setCustomId("call-233-open") 173 | .setLabel(t233("renewNumber")) 174 | .setEmoji("💸") 175 | .setDisabled(this.account!.balance < 500), 176 | ); 177 | 178 | this.interaction.reply({ 179 | embeds: [embed], 180 | components: [actionRow], 181 | ephemeral: true, 182 | }); 183 | } 184 | async fourOneOne(): Promise { 185 | this.interaction.reply(fourOneOneMainMenu); 186 | } 187 | } 188 | 189 | const mainMenuEmbed = new EmbedBuilder() 190 | .setColor(config.colors.yellowbook) 191 | .setTitle("Welcome to the DTel Yellowbook!") 192 | .setDescription("Please select an option."); 193 | 194 | const mainMenuRow = new ActionRowBuilder(); 195 | mainMenuRow.addComponents([ 196 | new StringSelectMenuBuilder() 197 | .setCustomId("call-411-selector") 198 | .setPlaceholder("Options") 199 | .setOptions([ 200 | new StringSelectMenuOptionBuilder() 201 | .setLabel("Search") 202 | .setDescription("Search through the Yellowbook") 203 | .setEmoji({ name: "🔍" }) 204 | .setValue("search"), 205 | new StringSelectMenuOptionBuilder() 206 | .setLabel("Manage") 207 | .setDescription("Create, edit or delete your Yellowbook entry") 208 | .setEmoji({ name: "✍️" }) 209 | .setValue("manage"), 210 | new StringSelectMenuOptionBuilder() 211 | .setLabel("Special Numbers") 212 | .setDescription("Find information about our special numbers") 213 | .setEmoji({ name: "📲" }) 214 | .setValue("special"), 215 | new StringSelectMenuOptionBuilder() 216 | .setLabel("Customer Support") 217 | .setDescription("Call Customer Support") 218 | .setEmoji({ name: "📞" }) 219 | .setValue("support"), 220 | new StringSelectMenuOptionBuilder() 221 | .setLabel("VIP Options") 222 | .setDescription("Access special VIP options") 223 | .setEmoji({ name: "🌟" }) 224 | .setValue("vip"), 225 | new StringSelectMenuOptionBuilder() 226 | .setLabel("Exit") 227 | .setDescription("Close this menu") 228 | .setEmoji({ name: "❌" }) 229 | .setValue("exit"), 230 | ]), 231 | ]); 232 | 233 | export const fourOneOneMainMenu: BaseMessageOptions = { 234 | embeds: [mainMenuEmbed], 235 | components: [mainMenuRow], 236 | }; 237 | -------------------------------------------------------------------------------- /src/commands/standard/daily.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { APIEmbed } from "discord.js"; 3 | import { PermissionLevel } from "../../interfaces/commandData"; 4 | import Command from "../../internals/commandProcessor"; 5 | 6 | export default class Daily extends Command { 7 | async run(): Promise { 8 | if (this.account?.dailyClaimedAt) { 9 | const oneDayAfter = dayjs(this.account.dailyClaimedAt).add(1, "day"); 10 | 11 | // If it's too early 12 | if (oneDayAfter.isAfter(Date.now())) { 13 | this.interaction.reply({ 14 | embeds: [{ 15 | color: this.config.colors.info, 16 | ...this.t("alreadyClaimedEmbed", { 17 | timeRemaining: oneDayAfter.fromNow(false), 18 | }) as APIEmbed, 19 | }], 20 | ephemeral: true, 21 | }); 22 | return; 23 | } 24 | } 25 | 26 | const allDailyAmounts = this.config.dailies; 27 | let creditCount: number; 28 | 29 | const perms = await this.client.getPerms(this.interaction.user.id); 30 | switch (perms) { 31 | case PermissionLevel.maintainer: { 32 | creditCount = allDailyAmounts.boss; 33 | break; 34 | } 35 | case PermissionLevel.manager: { 36 | creditCount = allDailyAmounts.manager; 37 | break; 38 | } 39 | case PermissionLevel.customerSupport: { 40 | creditCount = allDailyAmounts.customerSupport; 41 | break; 42 | } 43 | case PermissionLevel.contributor: { 44 | creditCount = allDailyAmounts.contributor; 45 | break; 46 | } 47 | case PermissionLevel.donator: { 48 | creditCount = allDailyAmounts.donator; 49 | break; 50 | } 51 | default: { 52 | creditCount = allDailyAmounts.default; 53 | break; 54 | } 55 | } 56 | 57 | this.account!.balance += creditCount; 58 | 59 | await this.db.accounts.update({ 60 | where: { 61 | id: this.interaction.user.id, 62 | }, 63 | data: { 64 | balance: this.account!.balance, 65 | dailyClaimedAt: new Date(), 66 | }, 67 | }); 68 | 69 | this.interaction.reply({ 70 | embeds: [{ 71 | color: this.config.colors.success, 72 | ...this.t("claimedSuccessfully", { 73 | balance: this.account!.balance, 74 | noNewCredits: creditCount, 75 | }) as APIEmbed, 76 | }], 77 | ephemeral: true, 78 | }); 79 | 80 | this.client.log(`📆 \`${this.interaction.user.username}\` (${this.interaction.user.id}) has claimed their \`${creditCount}\` daily credits.`); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/commands/standard/help.ts: -------------------------------------------------------------------------------- 1 | import { InteractionReplyOptions, APIEmbed } from "discord.js"; 2 | import Command from "../../internals/commandProcessor"; 3 | 4 | export default class Help extends Command { 5 | async run(): Promise { 6 | const toSend: InteractionReplyOptions = { 7 | embeds: [{ 8 | color: this.config.colors.info, 9 | author: { 10 | name: this.client.user.username, 11 | icon_url: this.client.user.displayAvatarURL(), 12 | url: this.config.siteLink, 13 | }, 14 | ...(this.t("embed") as APIEmbed), 15 | }], 16 | 17 | ephemeral: true, 18 | }; 19 | 20 | this.interaction.reply(toSend); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/commands/standard/info.ts: -------------------------------------------------------------------------------- 1 | import Command from "../../internals/commandProcessor"; 2 | import config from "../../config/config"; 3 | import { APIEmbed } from "discord.js"; 4 | 5 | export default class Info extends Command { 6 | async run(): Promise { 7 | this.interaction.reply({ 8 | embeds: [{ 9 | color: config.colors.info, 10 | author: { 11 | name: "DTel", 12 | icon_url: this.client.user.displayAvatarURL(), 13 | url: config.siteLink, 14 | }, 15 | 16 | ...this.t("embed", { 17 | siteLink: config.siteLink, 18 | inviteLink: config.botInvite, 19 | suggestLink: config.suggestLink, 20 | applyLink: config.applyLink, 21 | guildInvite: config.guildInvite, 22 | paymentLink: config.paymentLink, 23 | interpolation: { 24 | escapeValue: false, 25 | }, 26 | }) as APIEmbed, 27 | }], 28 | ephemeral: true, 29 | }); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/commands/standard/invite.ts: -------------------------------------------------------------------------------- 1 | import Links from "./links"; 2 | export default Links; 3 | -------------------------------------------------------------------------------- /src/commands/standard/links.ts: -------------------------------------------------------------------------------- 1 | import Command from "../../internals/commandProcessor"; 2 | import config from "../../config/config"; 3 | import { APIEmbed } from "discord.js"; 4 | import { t } from "i18next"; 5 | 6 | export default class Links extends Command { 7 | async run(): Promise { 8 | this.interaction.reply({ 9 | embeds: [{ 10 | color: config.colors.info, 11 | author: { 12 | name: this.client.user.username, 13 | icon_url: this.client.user.displayAvatarURL(), 14 | url: config.siteLink, 15 | }, 16 | ...(t("commands.links.embed") as APIEmbed), 17 | }], 18 | ephemeral: true, 19 | }); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/commands/standard/mailbox clear.ts: -------------------------------------------------------------------------------- 1 | import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js"; 2 | import Command from "../../internals/commandProcessor"; 3 | 4 | export default class MailboxClear extends Command { 5 | async run(): Promise { 6 | const mailbox = await this.fetchMailbox(); 7 | 8 | if (mailbox.messages.length === 0) { 9 | this.interaction.reply({ 10 | embeds: [this.client.errorEmbed("Your mailbox is already empty!")], 11 | ephemeral: true, 12 | }); 13 | return; 14 | } 15 | 16 | this.interaction.reply({ 17 | embeds: [this.client.warningEmbed("Are you sure you want to do this? Your messages cannot be recovered.")], 18 | components: [ 19 | new ActionRowBuilder().addComponents([ 20 | new ButtonBuilder() 21 | .setCustomId("mailbox-clear-confirm") 22 | .setEmoji("👍") 23 | .setLabel("Confirm") 24 | .setStyle(ButtonStyle.Danger), 25 | ]), 26 | ], 27 | ephemeral: true, 28 | }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/commands/standard/mailbox delete.ts: -------------------------------------------------------------------------------- 1 | import { StringSelectMenuOptionBuilder } from "@discordjs/builders"; 2 | import { ActionRowBuilder, StringSelectMenuBuilder } from "discord.js"; 3 | import Command from "../../internals/commandProcessor"; 4 | 5 | export default class MailboxDelete extends Command { 6 | async run(): Promise { 7 | const selectMenu = new StringSelectMenuBuilder() 8 | .setCustomId("mailbox-delete-select") 9 | .setPlaceholder("Select a message to delete") 10 | .setMaxValues(1); 11 | 12 | const mailbox = await this.fetchMailbox(); 13 | if (mailbox.messages.length === 0) { 14 | this.interaction.reply({ 15 | embeds: [this.client.errorEmbed("Your mailbox is empty!")], 16 | }); 17 | return; 18 | } 19 | 20 | for (let i = 0; i < mailbox.messages.length && i < 25; i++) { 21 | const message = mailbox.messages[i]; 22 | const opt = new StringSelectMenuOptionBuilder(); 23 | opt.setLabel(message.id); 24 | opt.setDescription(message.message); 25 | opt.setValue(message.id); 26 | 27 | selectMenu.addOptions(opt); 28 | } 29 | 30 | 31 | let description = "Select a message to delete."; 32 | if (mailbox.messages.length >= 25) { 33 | description += " Can't see some messages? Only the first 25 messages are shown."; 34 | } 35 | 36 | this.interaction.reply({ 37 | embeds: [{ 38 | color: this.config.colors.info, 39 | title: "📭 Delete Messages", 40 | description: description, 41 | }], 42 | components: [new ActionRowBuilder().addComponents([selectMenu])], 43 | }); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/commands/standard/mailbox messages.ts: -------------------------------------------------------------------------------- 1 | import { ActionRowBuilder, ButtonBuilder, ButtonStyle, CommandInteraction, EmbedBuilder, EmbedField, MessageComponentInteraction } from "discord.js"; 2 | import config from "../../config/config"; 3 | import { db } from "../../database/db"; 4 | import { client } from "../../dtel"; 5 | import Command from "../../internals/commandProcessor"; 6 | 7 | export default class MailboxMessages extends Command { 8 | async run(): Promise { 9 | MailboxMessages.displayMessages(this.interaction, this.number!.number, 1); 10 | } 11 | 12 | static async displayMessages(interaction: CommandInteraction|MessageComponentInteraction, number: string, page = 1) { 13 | const mailbox = await db.mailbox.upsert({ 14 | create: { 15 | number, 16 | }, 17 | where: { 18 | number, 19 | }, 20 | update: {}, 21 | }); 22 | 23 | if (mailbox.messages.length === 0) { 24 | interaction.reply({ 25 | embeds: [client.errorEmbed("Your mailbox is empty.")], 26 | ephemeral: true, 27 | }); 28 | return; 29 | } 30 | 31 | const embed = new EmbedBuilder() 32 | .setColor(config.colors.info) 33 | .setTitle(`📬 You have ${mailbox.messages.length} messages.`); 34 | 35 | const itemsPerPage = 5; 36 | const offset = (page - 1) * itemsPerPage; 37 | const pages = Math.ceil(mailbox.messages.length / itemsPerPage); 38 | 39 | if (offset > (itemsPerPage * page) || offset > mailbox.messages.length) { 40 | interaction.reply({ 41 | embeds: [client.errorEmbed("Your mailbox is not sufficiently full.")], 42 | ephemeral: true, 43 | }); 44 | return; 45 | } 46 | 47 | const fields: EmbedField[] = []; 48 | 49 | for (let i = offset; i < (itemsPerPage * page) && i < mailbox.messages.length; i++) { 50 | const msg = mailbox.messages[i]; 51 | fields.push({ 52 | name: `ID \`${msg.id}\` from ${msg.from}`, 53 | value: msg.message, 54 | inline: false, 55 | }); 56 | } 57 | 58 | embed.setFields(fields); 59 | 60 | if (pages > 1) { 61 | embed.setFooter({ 62 | text: `Page ${page}/${pages}`, 63 | }); 64 | } 65 | 66 | const actionRow = new ActionRowBuilder(); 67 | 68 | if (pages > 1) { 69 | actionRow.addComponents( 70 | new ButtonBuilder() 71 | .setCustomId(`mailbox-messages-prev-${page - 1}`) 72 | .setLabel("Previous") 73 | .setEmoji("◀️") 74 | .setStyle(ButtonStyle.Secondary) 75 | .setDisabled(page === 1), 76 | ); 77 | actionRow.addComponents( 78 | new ButtonBuilder() 79 | .setCustomId(`mailbox-messages-next-${page + 1}`) 80 | .setLabel("Next") 81 | .setEmoji("▶️") 82 | .setStyle(ButtonStyle.Primary) 83 | .setDisabled(page === pages), 84 | ); 85 | } 86 | 87 | 88 | if (interaction instanceof MessageComponentInteraction) { 89 | interaction.message.edit({ 90 | embeds: [embed], 91 | components: [actionRow], 92 | }); 93 | 94 | interaction.deferUpdate(); 95 | } else { 96 | interaction.reply({ 97 | embeds: [embed], 98 | components: [actionRow], 99 | }); 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/commands/standard/mailbox settings.ts: -------------------------------------------------------------------------------- 1 | import { ActionRowBuilder } from "@discordjs/builders"; 2 | import { ModalBuilder, TextInputBuilder, TextInputStyle } from "discord.js"; 3 | import Command from "../../internals/commandProcessor"; 4 | 5 | export default class MailboxSettings extends Command { 6 | async run(): Promise { 7 | const mailbox = await this.fetchMailbox(); 8 | 9 | const modal = new ModalBuilder() 10 | .setCustomId("mailbox-settings-update") 11 | .setTitle("Mailbox Settings") 12 | .addComponents([ 13 | new ActionRowBuilder().addComponents([ 14 | new TextInputBuilder() 15 | .setCustomId("autoreply") 16 | .setValue(mailbox.autoreply) 17 | .setLabel("Automatic Reply") 18 | .setStyle(TextInputStyle.Short) 19 | .setMinLength(0) 20 | .setRequired(true), 21 | ]), 22 | new ActionRowBuilder().addComponents([ 23 | new TextInputBuilder() 24 | .setCustomId("active") 25 | .setValue(mailbox.receiving ? "ON" : "OFF") 26 | .setLabel("Message Receiving") 27 | .setPlaceholder("ON/OFF") 28 | .setRequired(true) 29 | .setMinLength(2) 30 | .setMaxLength(3) 31 | .setStyle(TextInputStyle.Short), 32 | ]), 33 | ]); 34 | 35 | this.interaction.showModal(modal); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/commands/standard/mention list.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder } from "discord.js"; 2 | import Command from "../../internals/commandProcessor"; 3 | 4 | export default class MentionList extends Command { 5 | async run(): Promise { 6 | const mentions = this.number!.mentions; 7 | 8 | const embed = EmbedBuilder.from(this.t("listEmbed", { 9 | list: mentions.length ? mentions.map(m => m.toString()).join(", ") : this.t("listEmpty"), 10 | interpolation: { 11 | escapeValue: false, 12 | }, 13 | })).setColor(this.config.colors.info); 14 | 15 | this.interaction.reply({ 16 | embeds: [embed], 17 | ephemeral: true, 18 | }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/commands/standard/mention remove.ts: -------------------------------------------------------------------------------- 1 | import { ActionRowBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder } from "discord.js"; 2 | import Command from "../../internals/commandProcessor"; 3 | import { getUsername } from "../../internals/utils"; 4 | 5 | export default class MentionRemove extends Command { 6 | async run(): Promise { 7 | if (this.number?.mentions.length === 0) { 8 | this.interaction.reply({ 9 | embeds: [ 10 | this.client.errorEmbed(this.t("listEmpty")), 11 | ], 12 | }); 13 | return; 14 | } 15 | 16 | await this.interaction.deferReply(); 17 | 18 | const selectMenu = new StringSelectMenuBuilder() 19 | .setPlaceholder(this.t("selectPrompt")) 20 | .setCustomId("mention-remove-selector"); 21 | 22 | for (const i of this.number!.mentions) { 23 | const user = await this.client.getUser(i).catch(() => null); 24 | selectMenu.options.push( 25 | new StringSelectMenuOptionBuilder() 26 | .setLabel(`${user ? getUsername(user) : i}`) 27 | .setValue(i) 28 | .setDescription(i), 29 | ); 30 | } 31 | 32 | await this.interaction.followUp({ 33 | components: [new ActionRowBuilder().addComponents(selectMenu)], 34 | }); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/commands/standard/mention toggle.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder } from "discord.js"; 2 | import Command from "../../internals/commandProcessor"; 3 | 4 | export default class MentionToggle extends Command { 5 | async run(): Promise { 6 | let indexOfMention = this.number!.mentions.indexOf(this.interaction.user.id); 7 | if (indexOfMention === -1) indexOfMention = this.number!.mentions.indexOf(`<@${this.interaction.user.id}>`); 8 | 9 | // If they're already being mentioned 10 | if (indexOfMention > -1) { 11 | this.number!.mentions.splice(indexOfMention, 1); 12 | } else { 13 | if (this.number!.mentions.length >= 25) { 14 | this.interaction.reply({ 15 | embeds: [ 16 | this.client.errorEmbed(this.t("listFull")), 17 | ], 18 | }); 19 | return; 20 | } 21 | this.number?.mentions.push(this.interaction.user.id); 22 | } 23 | 24 | await this.db.numbers.update({ 25 | where: { 26 | number: this.number!.number, 27 | }, 28 | data: { 29 | mentions: this.number!.mentions, 30 | }, 31 | }); 32 | 33 | const embed = EmbedBuilder.from(this.t("toggleEmbed", { 34 | addedOrRemoved: indexOfMention === -1 ? "$t(generic.addedTo)" : "$t(generic.removedFrom)", 35 | interpolation: { 36 | skipOnVariables: false, 37 | }, 38 | })).setColor(this.config.colors.success); 39 | 40 | this.interaction.reply({ 41 | embeds: [embed], 42 | ephemeral: true, 43 | }); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/commands/standard/pay common.ts: -------------------------------------------------------------------------------- 1 | // This command's embed editing kinda spiraled out of control 2 | // Feel free to refactor 3 | import { Accounts } from "@prisma/client"; 4 | import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType, EmbedBuilder, User } from "discord.js"; 5 | import { PermissionLevel } from "../../interfaces/commandData"; 6 | import Command from "../../internals/commandProcessor"; 7 | import { formatBalance, getUsername, upperFirst } from "../../internals/utils"; 8 | 9 | export default abstract class PayCommonFunctions extends Command { 10 | async payUser(toPay: Accounts, user: User): Promise { 11 | if (user.bot) { 12 | this.interaction.reply({ 13 | embeds: [this.client.errorEmbed(this.t("wasteOfMoney"))], 14 | }); 15 | return; 16 | } 17 | 18 | const totalCost = this.interaction.options.getInteger("credits", true); 19 | 20 | if (totalCost < this.config.minTransfer) { 21 | this.interaction.reply({ 22 | embeds: [{ 23 | color: this.config.colors.error, 24 | ...this.t("tooLow"), 25 | }], 26 | }); 27 | return; 28 | } 29 | 30 | const senderPerms = await this.client.getPerms(this.interaction.user.id); 31 | const recipientPerms = await this.client.getPerms(user.id); 32 | 33 | const transferRate = recipientPerms as PermissionLevel >= PermissionLevel.donator || senderPerms as PermissionLevel >= PermissionLevel.donator ? this.config.vipTransferRate : this.config.normalTransferRate; 34 | 35 | const totalReceived = transferRate * totalCost; 36 | const fee = Math.round(totalCost - totalReceived); 37 | 38 | if (totalCost > this.account!.balance) { 39 | this.interaction.reply({ 40 | embeds: [this.client.errorEmbed(this.t("cantAfford"))], 41 | }); 42 | return; 43 | } 44 | 45 | const authorTag = this.userDisplayName; 46 | const recipientTag = getUsername(user); 47 | 48 | const embed = EmbedBuilder.from({ 49 | color: this.config.colors.info, 50 | author: { 51 | name: authorTag, 52 | icon_url: this.interaction.user.displayAvatarURL(), 53 | }, 54 | 55 | ...this.t("confirmEmbedOptions"), 56 | ...this.t("embed", { 57 | displayName: `${recipientTag} (${user.id})`, 58 | preFeeToSend: totalCost, 59 | fee: formatBalance(fee), 60 | postFeeCost: formatBalance(totalReceived), 61 | newBalance: formatBalance(this.account!.balance - totalCost), 62 | message: this.interaction.options.getInteger("message", false) || "None", 63 | }), 64 | }).setTimestamp(new Date()); 65 | 66 | const reply = await this.interaction.reply({ 67 | embeds: [embed], 68 | 69 | components: [ 70 | new ActionRowBuilder().addComponents([ 71 | new ButtonBuilder() 72 | .setEmoji("✅") 73 | .setLabel(upperFirst(this.genericT("continue"))) 74 | .setStyle(ButtonStyle.Primary) 75 | .setCustomId("dtelnoreg-pay-continue"), 76 | new ButtonBuilder() 77 | .setEmoji("✖️") 78 | .setLabel(upperFirst(this.genericT("cancel"))) 79 | .setStyle(ButtonStyle.Danger) 80 | .setCustomId("dtelnoreg-pay-cancel"), 81 | ]), 82 | ], 83 | }); 84 | 85 | try { 86 | const buttonPress = await reply.awaitMessageComponent({ 87 | filter: interaction => interaction.user.id === this.interaction.user.id, 88 | time: 1 * 60 * 1000, // 1 minute 89 | componentType: ComponentType.Button, 90 | }); 91 | 92 | if (buttonPress.customId.endsWith("cancel")) throw new Error(); 93 | } catch { 94 | // TODO: Cancel transaction by disabling buttons (?) 95 | } 96 | 97 | // From here we know that they accepted (because of above) 98 | 99 | // Update the sender 100 | await this.db.accounts.update({ 101 | where: { 102 | id: this.interaction.user.id, 103 | }, 104 | data: { 105 | balance: { 106 | decrement: totalCost, 107 | }, 108 | }, 109 | }); 110 | // Update the recipient and get their new balance 111 | const otherPersonsNewBalance = (await this.db.accounts.update({ 112 | where: { 113 | id: this.interaction.user.id, 114 | }, 115 | data: { 116 | balance: { 117 | increment: totalReceived, 118 | }, 119 | }, 120 | select: { 121 | balance: true, 122 | }, 123 | })).balance; 124 | 125 | // Edit the embed -- to change from confirm to receipt 126 | embed 127 | .setColor(this.config.colors.receipt) 128 | .setFooter(null) 129 | .setTitle(this.t("transactionCompleteEmbedOptions.title")) 130 | .setDescription(this.t("transactionCompleteEmbedOptions.description")); 131 | 132 | embed.spliceFields(1, 1, { 133 | name: this.t("transactionDetails"), 134 | value: this.t("editedTransactionDescription", { 135 | preFeeCost: totalCost, 136 | fee, 137 | feePercentage: (100 - (transferRate * 100)).toString(), 138 | }), 139 | }); 140 | 141 | // Send the receipt to the sender 142 | await this.interaction.editReply({ 143 | embeds: [embed], 144 | components: [], 145 | }); 146 | 147 | // Edit the embed again -- to DM user 148 | embed 149 | .setTitle(`💸 ${upperFirst(this.genericT("received"))}`) 150 | .setDescription(this.t("moneySent")) 151 | .setFooter(null); 152 | 153 | // Edit user field 154 | embed.spliceFields(0, 1, { 155 | name: this.t("newBalance"), 156 | value: `${this.config.dtsEmoji} ${formatBalance(otherPersonsNewBalance)}`, 157 | }); 158 | embed.spliceFields(2, 1, { 159 | name: this.t("sender"), 160 | value: authorTag, 161 | }); 162 | 163 | // Try to DM the user 164 | user.send({ 165 | embeds: [embed], 166 | }).catch(() => null); 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/commands/standard/pay id.ts: -------------------------------------------------------------------------------- 1 | import PayCommonFunctions from "./pay common"; 2 | 3 | export default class Pay extends PayCommonFunctions { 4 | async run(): Promise { 5 | const userID = this.interaction.options.getString("id", true); 6 | const user = await this.client.getUser(userID); 7 | 8 | if (!user) { 9 | this.interaction.reply({ 10 | embeds: [this.client.errorEmbed(this.t("userNotFound"))], 11 | }); 12 | return; 13 | } 14 | 15 | const account = await this.fetchAccount(userID); 16 | 17 | return this.payUser(account, user); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/commands/standard/pay user.ts: -------------------------------------------------------------------------------- 1 | import PayCommonFunctions from "./pay common"; 2 | 3 | export default class Pay extends PayCommonFunctions { 4 | async run(): Promise { 5 | const user = this.interaction.options.getUser("user", true); 6 | 7 | const account = await this.fetchAccount(user.id); 8 | 9 | return this.payUser(account, user); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/commands/standard/ping.ts: -------------------------------------------------------------------------------- 1 | import Command from "../../internals/commandProcessor"; 2 | import config from "../../config/config"; 3 | 4 | export default class Ping extends Command { 5 | async run(): Promise { 6 | // No real point localising a test command 7 | const embed = { 8 | color: config.colors.info, 9 | title: "Pong", 10 | description: `API Latency: ${this.client.ws.ping}ms\nMeasured time: ${Date.now() - Number(this.interaction.createdAt)}ms`, 11 | }; 12 | 13 | this.interaction.reply({ embeds: [embed] }); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/commands/standard/rcall.ts: -------------------------------------------------------------------------------- 1 | import Command from "../../internals/commandProcessor"; 2 | import { Numbers, Prisma } from "@prisma/client"; 3 | import CallCommand from "./call"; 4 | 5 | export default class RCall extends Command { 6 | async run(): Promise { 7 | await this.interaction.deferReply(); 8 | 9 | let toCall: Numbers | null = null; 10 | 11 | let cycle = 0; 12 | // Presume the Phonebook always has at least 1 number 13 | while (!toCall) { 14 | cycle++; 15 | if (cycle > 100) { 16 | this.interaction.editReply("❌ Couldn't find a number to call."); 17 | return; 18 | } 19 | 20 | const randomEntry = (await this.db.phonebook.aggregateRaw({ 21 | pipeline: [{ 22 | $match: { 23 | number: { 24 | $ne: this.number!.number, 25 | }, // Don't get this channel's number 26 | }, 27 | }, { 28 | $sample: { 29 | size: 1, 30 | }, 31 | }], 32 | }) as unknown as Prisma.JsonObject[])[0]; 33 | 34 | 35 | const number = await this.db.numbers.findUnique({ 36 | where: { 37 | number: randomEntry._id as string, 38 | }, 39 | include: { 40 | incomingCalls: true, 41 | outgoingCalls: true, 42 | }, 43 | }); 44 | 45 | // If this number doesn't exist then delete it's Yellowbook entry and try again. 46 | if (!number) { 47 | await this.db.phonebook.delete({ 48 | where: { 49 | number: randomEntry._id as string, 50 | }, 51 | }); 52 | continue; 53 | } 54 | 55 | if (this.config.aliasNumbers["*611"] === number.number) { 56 | continue; 57 | } 58 | 59 | // Number is in a call, try again 60 | if (number?.outgoingCalls.length > 0 || number.incomingCalls.length > 0) continue; 61 | 62 | try { 63 | const channel = await this.client.getChannel(number.channelID); 64 | if (!channel) { 65 | // this.client.deleteNumber(number.number); this feels unsafe 66 | continue; 67 | } 68 | } catch { 69 | // Don't delete if there's potential for an unavailable guild to delete a number 70 | continue; 71 | } 72 | 73 | if (number.expiry < new Date() || (this.number?.blocked && this.number.blocked.includes(number.number))) continue; 74 | 75 | toCall = number; 76 | } 77 | 78 | this.interaction.editReply("☎️ Found a number! Dialling..."); 79 | 80 | CallCommand.call(this.interaction, toCall.number, this.number!, true, true); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/commands/standard/report.ts: -------------------------------------------------------------------------------- 1 | // This file is left here as a reminder for a potential feature 2 | // Safeguarding feature 3 | // Allow users to report a number to CS without calling 4 | // Args: 5 | // Number: Optional, not this.number. If not given it will try to get their current/last call 6 | // Reason: required. Self-explanatory 7 | -------------------------------------------------------------------------------- /src/commands/standard/strikes.ts: -------------------------------------------------------------------------------- 1 | import { EmbedField } from "discord.js"; 2 | import { PermissionLevel } from "../../interfaces/commandData"; 3 | import Command from "../../internals/commandProcessor"; 4 | import { getUsername } from "../../internals/utils"; 5 | 6 | export default class Strikes extends Command { 7 | async run() { 8 | const offender = this.interaction.options.getString("offender", false) || this.interaction.user.id; 9 | 10 | if (offender != this.interaction.user.id && await this.client.getPerms(this.interaction.user.id) as number < PermissionLevel.customerSupport) { 11 | this.interaction.reply({ 12 | embeds: [this.client.errorEmbed("You don't have permission to check the strikes of others!")], 13 | }); 14 | return; 15 | } 16 | 17 | await this.interaction.deferReply(); 18 | 19 | const strikes = await this.db.strikes.findMany({ 20 | where: { 21 | offender, 22 | }, 23 | }); 24 | 25 | if (strikes.length === 0) { 26 | this.interaction.editReply({ 27 | embeds: [{ 28 | color: this.config.colors.success, 29 | title: "✨ Clean!", 30 | description: "This ID has no strikes!", 31 | }], 32 | }); 33 | return; 34 | } 35 | 36 | const fields: EmbedField[] = []; 37 | 38 | for (const strike of strikes) { 39 | const staff = await this.client.users.fetch(strike.created.by).catch(() => null); 40 | 41 | fields.push({ 42 | name: `Strike \`${strike.id}\` by ${staff ? getUsername(staff) : `Unknown (${strike.created.by})`}`, 43 | value: `• **Reason**: ${truncate(strike.reason, 800) || "No reason provided"}\n• **ID**: ${strike.id}`, 44 | inline: false, 45 | }); 46 | } 47 | 48 | await this.interaction.editReply({ 49 | embeds: [{ 50 | color: this.config.colors.yellowbook, 51 | title: "⚠️ Strikes found!", 52 | description: `This ${strikes[0].type === "USER" ? "user" : "server"} has ${strikes.length} strike${strikes.length != 1 ? "s" : ""}!`, 53 | fields, 54 | }], 55 | }); 56 | } 57 | } 58 | 59 | // Function to truncate a string to a certain length 60 | function truncate(str: string, len: number) { 61 | if (str.length > len) { 62 | return `${str.slice(0, len - 3)}...`; 63 | } else { 64 | return str; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/commands/standard/vote.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder } from "@discordjs/builders"; 2 | import CommandProcessor from "../../internals/commandProcessor"; 3 | 4 | export default class Vote extends CommandProcessor { 5 | async run() { 6 | this.interaction.reply({ 7 | ephemeral: true, 8 | embeds: [ 9 | new EmbedBuilder() 10 | .setColor(this.config.colors.info) 11 | .setAuthor({ 12 | name: this.client.user.username, 13 | iconURL: this.client.user.displayAvatarURL(), 14 | }) 15 | .setTitle("Vote for DTel") 16 | .setDescription(`You can find details about voting for DTel [here](${this.config.voteLink}). Voters will receive 10 credits per vote!`), 17 | ], 18 | }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/commands/standard/wizard.ts: -------------------------------------------------------------------------------- 1 | import { Numbers } from "@prisma/client"; 2 | import { ActionRowBuilder, ButtonBuilder, APIEmbed, ButtonStyle } from "discord.js"; 3 | import Command from "../../internals/commandProcessor"; 4 | 5 | // If someone could look over the complexity of this command and try to lower it that'd be helpful. 6 | 7 | export default class Wizard extends Command { 8 | async run(): Promise { 9 | const number: Numbers | null = await this.fetchNumber(); 10 | 11 | if (number) { 12 | this.interaction.reply({ 13 | embeds: [this.client.errorEmbed(this.t("errors.channelHasNumber", { number: number.number }))], 14 | ephemeral: true, 15 | }); 16 | return; 17 | } 18 | 19 | if (this.interaction.guild) { 20 | const guildConf = await this.db.guildConfigs.findUnique({ 21 | where: { 22 | id: this.interaction.guild.id, 23 | }, 24 | include: { 25 | numbers: true, 26 | }, 27 | }); 28 | 29 | 30 | if (!guildConf?.whitelisted) { 31 | let numberCount = 0; 32 | if (guildConf?.numbers?.length) { 33 | numberCount = guildConf?.numbers?.length; 34 | } else { 35 | numberCount = (await this.db.numbers.aggregate({ 36 | where: { 37 | guildID: this.interaction.guild.id, 38 | }, 39 | _count: { 40 | number: true, 41 | }, 42 | }))._count.number; 43 | } 44 | 45 | if (numberCount >= this.config.maxNumbers) { 46 | this.interaction.reply({ 47 | embeds: [this.client.errorEmbed(this.t("errors.unwhitelistedGuildHasTooManyNumbers"))], 48 | ephemeral: true, 49 | }); 50 | return; 51 | } 52 | } 53 | } 54 | 55 | this.interaction.reply({ 56 | embeds: [{ 57 | color: this.config.colors.yellowbook, 58 | 59 | ...this.t("introEmbed") as APIEmbed, 60 | }], 61 | components: [ 62 | new ActionRowBuilder() 63 | .addComponents(new ButtonBuilder() 64 | .setLabel("I'm ready!") 65 | .setCustomId("wizard-ready") 66 | .setEmoji("✔️") 67 | .setStyle(ButtonStyle.Primary)), 68 | ], 69 | }); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/commands/support/addcredit.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder, User } from "discord.js"; 2 | import { PermissionLevel } from "../../interfaces/commandData"; 3 | import Command from "../../internals/commandProcessor"; 4 | 5 | export default class AddCredit extends Command { 6 | async run(): Promise { 7 | const userID = this.interaction.options.getString("user", true); 8 | 9 | let user: User; 10 | try { 11 | user = await this.client.getUser(userID); 12 | } catch { 13 | this.targetUserNotFound(); 14 | return; 15 | } 16 | 17 | const perms = await this.client.getPerms(userID); 18 | 19 | if (userID === this.client.user.id) { 20 | this.interaction.reply({ 21 | ephemeral: true, 22 | embeds: [this.client.errorEmbed("I already have all the money!")], 23 | }); 24 | return; 25 | } else if (user.bot) { 26 | this.interaction.reply({ 27 | ephemeral: true, 28 | embeds: [this.client.errorEmbed("Are you sure you want to give them more money?", { title: "AI will destroy humans!!!" })], 29 | }); 30 | return; 31 | } else if (perms as number >= PermissionLevel.customerSupport && perms != PermissionLevel.maintainer) { 32 | this.interaction.reply({ 33 | ephemeral: true, 34 | embeds: [this.client.errorEmbed("That's not something you should be trying on the job!", { title: "Seriously?" })], 35 | }); 36 | return; 37 | } 38 | 39 | let account = await this.fetchAccount(userID); 40 | const amountOfCredits = this.interaction.options.getInteger("credits", true); 41 | 42 | if (amountOfCredits < 0 && (account.balance + amountOfCredits) < 0) { 43 | this.interaction.reply({ 44 | ephemeral: true, 45 | embeds: [this.client.errorEmbed("That would bankrupt this user!", { title: "Are you insane?" })], 46 | }); 47 | return; 48 | } 49 | 50 | account = await this.db.accounts.update({ 51 | where: { 52 | id: account.id, 53 | }, 54 | data: { 55 | balance: account.balance + amountOfCredits, 56 | }, 57 | }); 58 | 59 | const creditsAdded = amountOfCredits > 0; 60 | const addedOrRemoved = creditsAdded ? "Added" : "Removed"; 61 | 62 | const embed = EmbedBuilder.from({ 63 | color: this.config.colors.receipt, 64 | title: `${addedOrRemoved} credits!`, 65 | description: `${addedOrRemoved} ${this.config.dtsEmoji} ${Math.abs(amountOfCredits)} ${creditsAdded ? "to" : "from"} <@${userID}> (${userID})`, 66 | footer: { 67 | icon_url: this.interaction.user.displayAvatarURL(), 68 | text: `${this.userDisplayName} (${this.interaction.user.id})`, 69 | }, 70 | }).setTimestamp(new Date()); 71 | 72 | if (this.interaction.channelId != this.config.supportGuild.channels.management) { 73 | this.client.sendCrossShard({ 74 | embeds: [embed], 75 | }, this.config.supportGuild.channels.management); 76 | } 77 | 78 | this.interaction.reply({ 79 | embeds: [embed], 80 | }); 81 | 82 | user.send({ 83 | embeds: [{ 84 | color: this.config.colors.receipt, 85 | title: creditsAdded ? "💸 Cash!" : "Your balance changed", 86 | description: `A support member has ${addedOrRemoved.toLowerCase()} ${this.config.dtsEmoji}${Math.abs(amountOfCredits).toLocaleString()} ${creditsAdded ? "to" : "from"} your account.\nYou now have ${this.config.dtsEmoji}${account.balance}.`, 87 | }], 88 | }).catch(() => null); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/commands/support/blacklist.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder } from "discord.js"; 2 | import { PermissionLevel } from "../../interfaces/commandData"; 3 | import Command from "../../internals/commandProcessor"; 4 | import { parseNumber } from "../../internals/utils"; 5 | 6 | export default class Blacklist extends Command { 7 | async run(): Promise { 8 | const toBlacklist = parseNumber(this.interaction.options.getString("id", true)); 9 | 10 | const possibilities = await this.client.resolveGuildChannelNumberUser(toBlacklist); 11 | 12 | if (possibilities.user) { 13 | if (possibilities.user.bot) { 14 | this.interaction.reply({ 15 | embeds: [this.client.errorEmbed("Do not try to blacklist my brothers!", { title: "❌ User is a bot" })], 16 | }); 17 | return; 18 | } 19 | 20 | if (await this.client.getPerms(possibilities.user.id) as number >= PermissionLevel.customerSupport) { 21 | this.interaction.reply({ 22 | embeds: [this.client.errorEmbed("You can't get rid of someone that easily...", { title: "❌ Unfair competition" })], 23 | ephemeral: true, 24 | }); 25 | return; 26 | } 27 | } 28 | 29 | let idToBlacklist: string | undefined; 30 | if (possibilities.user) { 31 | idToBlacklist = possibilities.user.id; 32 | } else if (possibilities.guild) { 33 | idToBlacklist = possibilities.guild.id; 34 | } 35 | 36 | if (!idToBlacklist) { 37 | this.interaction.reply({ 38 | embeds: [this.client.errorEmbed("ID could not be resolved to a number, server, user or channel.")], 39 | ephemeral: true, 40 | }); 41 | return; 42 | } 43 | 44 | if (idToBlacklist === this.interaction.user.id) { 45 | this.interaction.reply({ content: `You dumb :b:oi, don't blacklist yourself!`, ephemeral: true }); 46 | return; 47 | } 48 | 49 | if (idToBlacklist === this.config.supportGuild.id) { 50 | this.interaction.reply({ 51 | embeds: [{ 52 | color: this.config.colors.error, 53 | title: "Turning against us?", 54 | description: "As if we'd would allow you to do this...", 55 | }], 56 | ephemeral: true, 57 | }); 58 | return; 59 | } 60 | 61 | const isBlacklisted = await this.db.blacklist.findUnique({ 62 | where: { 63 | id: idToBlacklist, 64 | }, 65 | }); 66 | 67 | const embed: EmbedBuilder = EmbedBuilder.from({ 68 | color: this.config.colors.yellowbook, 69 | author: { 70 | // eslint-disable-next-line @typescript-eslint/no-extra-parens 71 | icon_url: possibilities.user ? possibilities.user.displayAvatarURL() : (possibilities.guild?.icon ? possibilities.guild.iconURL()! : this.client.user.defaultAvatarURL), 72 | name: possibilities.user ? possibilities.user.username : possibilities.guild!.name, 73 | }, 74 | footer: { 75 | icon_url: this.interaction.user.displayAvatarURL(), 76 | text: `By ${this.interaction.user.username} (${this.interaction.user.id})`, 77 | }, 78 | }).setTimestamp(new Date()); 79 | 80 | const toDM = possibilities.user || await possibilities.guild?.fetchOwner(); 81 | 82 | if (!isBlacklisted) { 83 | const reason = this.interaction.options.getString("reason", false); 84 | 85 | await this.db.blacklist.create({ 86 | data: { 87 | id: idToBlacklist, 88 | reason: reason || undefined, 89 | }, 90 | }); 91 | 92 | embed.setTitle(`Added ${possibilities.user ? "user" : "guild"} to the blacklist.`); 93 | if (possibilities.guild) { 94 | possibilities.guild.leave(); 95 | } 96 | await this.db.numbers.deleteMany({ 97 | where: { 98 | guildID: possibilities.guild ? possibilities.guild.id : undefined, 99 | userID: possibilities.user ? possibilities.user.id : undefined, 100 | }, 101 | }); 102 | toDM?.send({ 103 | embeds: [{ 104 | color: this.config.colors.error, 105 | title: "You've been blacklisted", 106 | description: "This means you can no longer use DTel.\n\nIf you feel like this action was unfair, you can dispute it with one of the bosses in the support server. (Don't try calling *611, you can't use the bot.) If you evade this blacklist with an alternate account, we will ignore your appeals.", 107 | }], 108 | }).catch(() => null); 109 | } else { 110 | await this.db.blacklist.delete({ 111 | where: { 112 | id: idToBlacklist, 113 | }, 114 | }); 115 | 116 | embed.setTitle(`Removed ${possibilities.user ? "user" : "guild"} from the blacklist.`); 117 | toDM?.send({ 118 | embeds: [{ 119 | color: this.config.colors.info, 120 | title: "You've been pardoned", 121 | description: "You have been removed from the blacklist, however, your strikes have not been cleared. Any further violation will put you back on the blacklist.", 122 | }], 123 | }).catch(() => null); 124 | } 125 | 126 | this.interaction.reply({ embeds: [embed] }); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/commands/support/cinfo.ts: -------------------------------------------------------------------------------- 1 | import { ActiveCalls, ArchivedCalls, Numbers } from "@prisma/client"; 2 | import { EmbedBuilder } from "discord.js"; 3 | import Command from "../../internals/commandProcessor"; 4 | import { getUsername } from "../../internals/utils"; 5 | 6 | type activeOrArchivedCallWithNumbers = (ActiveCalls | ArchivedCalls); 7 | 8 | export default class CInfo extends Command { 9 | async run(): Promise { 10 | const callID = this.interaction.options.getString("call_id", true); 11 | 12 | let call: activeOrArchivedCallWithNumbers | null = await this.db.activeCalls.findUnique({ 13 | where: { 14 | id: callID, 15 | }, 16 | include: { 17 | from: true, 18 | to: true, 19 | }, 20 | }); 21 | 22 | if (!call) { 23 | call = await this.db.archivedCalls.findUnique({ 24 | where: { 25 | id: callID, 26 | }, 27 | }); 28 | } 29 | 30 | if (!call) { 31 | this.interaction.reply({ 32 | embeds: [this.client.errorEmbed("Couldn't find a call with that ID")], 33 | }); 34 | return; 35 | } 36 | 37 | const from = await this.db.numbers.findUnique({ 38 | where: { 39 | number: call.fromNum, 40 | }, 41 | }); 42 | 43 | const to = await this.db.numbers.findUnique({ 44 | where: { 45 | number: call.toNum, 46 | }, 47 | }); 48 | 49 | 50 | const startedUser = await this.client.getUser(call.started.by).catch(() => null); 51 | const pickedUpUser = call.pickedUp?.by ? await this.client.getUser(call.pickedUp.by).catch(() => null) : null; 52 | const endedUser = call.ended?.by ? await this.client.getUser(call.ended.by).catch(() => null) : null; 53 | 54 | // This looks like shit but works 55 | const embed = new EmbedBuilder() 56 | .setColor(this.config.colors.info) 57 | .setTitle("Call information") 58 | .setDescription([ 59 | `Showing details for call: \`${callID}\``, 60 | `Use \`/identify\` to identify the sender of a message.`, 61 | ].join("\n")) 62 | .addFields([{ 63 | name: "From", 64 | value: createNumberDetails(from), 65 | inline: true, 66 | }, { 67 | name: "To", 68 | value: createNumberDetails(to), 69 | inline: true, 70 | }, { 71 | name: "Information", 72 | value: [ 73 | `**Picked up**:`, 74 | `- At: ${blockFormat(call.pickedUp?.at.toString() || "N/A")}`, 75 | `- By: ${blockFormat(call.pickedUp ? `${pickedUpUser ? getUsername(pickedUpUser) : "Unknown"} (${pickedUpUser?.id})` : "N/A")}`, 76 | `**Ended**:`, 77 | `- At: ${blockFormat(call.ended?.at.toString() || "N/A")}`, 78 | `- By: ${blockFormat(call.ended ? `${endedUser ? getUsername(endedUser) : "Unknown"} (${endedUser?.id})` : "N/A")}`, 79 | `**Random**: ${blockFormat(call.randomCall ? "Yes" : "No")}`, 80 | // `Transferred by: ${blockFormat(call. ? "Yes" : "No")}`, 81 | `**Started**:`, 82 | `- At: ${blockFormat(call.started.at.toString())}`, 83 | `- By: ${blockFormat(`${startedUser?.tag} (${startedUser?.id})`)}`, 84 | ].join("\n"), 85 | inline: false, 86 | }]); 87 | 88 | this.interaction.reply({ embeds: [embed] }); 89 | } 90 | } 91 | 92 | const blockFormat = (block: string): string => `\`${block}\``; 93 | 94 | const createNumberDetails = (number: Numbers | null): string => { 95 | if (!number) { 96 | return `Number has been deleted.`; 97 | } 98 | 99 | return [ 100 | `**Number**: ${blockFormat(number.number)}`, 101 | `**Channel**: \`${blockFormat(number.channelID)}\``, 102 | `**Hidden**: \`${blockFormat(number.vip?.hidden ? `True` : `False`)}\``, 103 | `**Caller ID**: \`${blockFormat(number.vip?.name || `None`)}\``, 104 | ].join("\n"); 105 | }; 106 | -------------------------------------------------------------------------------- /src/commands/support/deassign.ts: -------------------------------------------------------------------------------- 1 | import { Numbers } from "@prisma/client"; 2 | import Command from "../../internals/commandProcessor"; 3 | import { parseNumber } from "../../internals/utils"; 4 | 5 | export default class Deassign extends Command { 6 | async run(): Promise { 7 | await this.interaction.deferReply(); 8 | 9 | const numberToDeassign = parseNumber(this.interaction.options.getString("number_or_channel", true)); 10 | 11 | let number: Numbers | null; 12 | if (numberToDeassign.length > 11) { 13 | number = await this.db.numbers.findUnique({ 14 | where: { 15 | channelID: numberToDeassign, 16 | }, 17 | }); 18 | } else { 19 | number = await this.db.numbers.findUnique({ 20 | where: { 21 | number: numberToDeassign, 22 | }, 23 | }); 24 | } 25 | 26 | if (!number) { 27 | this.interaction.editReply({ 28 | embeds: [this.client.errorEmbed("Couldn't find that number.")], 29 | }); 30 | return; 31 | } 32 | 33 | try { 34 | const res = await this.client.deleteNumber(number.number); 35 | 36 | if (res === false) throw new Error(); 37 | } catch { 38 | this.interaction.editReply({ 39 | embeds: [this.client.errorEmbed("Couldn't delete that number.")], 40 | }); 41 | return; 42 | } 43 | 44 | this.interaction.editReply({ 45 | embeds: [{ 46 | color: this.config.colors.success, 47 | author: { 48 | name: this.userDisplayName, 49 | icon_url: this.interaction.user.displayAvatarURL(), 50 | }, 51 | title: "R.I.P", 52 | description: `\`${number.number}\` has been deassigned.`, 53 | }], 54 | }); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/commands/support/ninfo.ts: -------------------------------------------------------------------------------- 1 | import { DMChannel, GuildTextBasedChannel, APIEmbed, TextBasedChannel, User } from "discord.js"; 2 | import { NumbersWithGuilds } from "../../interfaces/numbersWithGuilds"; 3 | import Command from "../../internals/commandProcessor"; 4 | import { parseNumber } from "../../internals/utils"; 5 | 6 | export default class NInfo extends Command { 7 | async run(): Promise { 8 | const toFind = parseNumber(this.interaction.options.getString("number_or_channel", true)); 9 | 10 | let number: NumbersWithGuilds | null; 11 | if (toFind.length > 11) { 12 | number = await this.db.numbers.findUnique({ 13 | where: { 14 | channelID: toFind, 15 | }, 16 | include: { 17 | guild: true, 18 | }, 19 | }); 20 | } else { 21 | number = await this.db.numbers.findUnique({ 22 | where: { 23 | number: toFind, 24 | }, 25 | include: { 26 | guild: true, 27 | }, 28 | }); 29 | } 30 | 31 | if (!number) { 32 | this.interaction.reply({ 33 | ephemeral: true, 34 | embeds: [this.client.errorEmbed("Couldn't find that number.")], 35 | }); 36 | return; 37 | } 38 | 39 | const isVIP = number.vip?.expiry && number.vip?.expiry > new Date(); 40 | 41 | const embed: APIEmbed = { 42 | color: isVIP ? this.config.colors.vip : this.config.colors.info, 43 | title: `Information about ${number.number}`, 44 | description: "Hit the button below for more information", 45 | fields: [], 46 | }; 47 | 48 | // Get the channel details 49 | const channel = await this.client.getChannel(number.channelID).catch(() => null) as TextBasedChannel; 50 | if (!channel) { 51 | this.interaction.reply({ 52 | ephemeral: true, 53 | embeds: [this.client.errorEmbed("The channel associated with that number couldn't be found.")], 54 | }); 55 | 56 | await this.db.numbers.delete({ 57 | where: { 58 | number: number.channelID, 59 | }, 60 | include: { 61 | phonebook: true, 62 | mailbox: true, 63 | }, 64 | }).catch(() => null); 65 | return; 66 | } 67 | 68 | let numberOwner: User; 69 | let ownerStrikeCount: number; 70 | let guildDescription = ""; 71 | let channelDescription = ""; 72 | 73 | // If this is a guild channel 74 | // We know we can see the channel at this point, so we can definitely see both the guild and the owner 75 | // If there is no guild, since we can see the channel we can see the user; 76 | if (!channel.isDMBased()) { 77 | const guild = await channel.guild.fetch(); 78 | ownerStrikeCount = await this.db.strikes.count({ 79 | where: { 80 | offender: guild.ownerId, 81 | }, 82 | }) || 0; 83 | 84 | numberOwner = await this.client.getUser(guild.ownerId); 85 | 86 | guildDescription = `${guild.name}\n\`${guild.id}\`\nWhitelisted: ${number.guild?.whitelisted ? "Yes" : "No"}`; 87 | channelDescription = `#${(channel as GuildTextBasedChannel).name}\n\`${channel.id}\``; 88 | 89 | let footerImage = this.client.user.displayAvatarURL(); 90 | if (guild.icon) { 91 | footerImage = guild.iconURL()!; 92 | } 93 | 94 | embed.footer = { 95 | icon_url: footerImage, 96 | text: guild.name, 97 | }; 98 | } else { 99 | const dmChannel = channel as DMChannel; 100 | 101 | numberOwner = await this.client.getUser(dmChannel.recipientId); // This should always be visible to us as we're in the server 102 | ownerStrikeCount = await this.db.strikes.count({ 103 | where: { 104 | offender: numberOwner.id, 105 | }, 106 | }) || 0; 107 | 108 | guildDescription = "DM Number"; 109 | channelDescription = `#*DM Channel*\n\`${channel.id}\``; 110 | 111 | embed.footer = { 112 | icon_url: numberOwner.displayAvatarURL(), 113 | text: `${numberOwner.username}#${numberOwner.discriminator}`, 114 | }; 115 | } 116 | 117 | let ownerDesc = `${numberOwner.username}#${numberOwner.discriminator}\n\`${numberOwner.id}\``; 118 | ownerDesc += `\nStrikes: ${ownerStrikeCount}`; 119 | 120 | embed.fields = [{ 121 | name: "Channel", 122 | value: channelDescription, 123 | inline: true, 124 | }, { 125 | name: "Owner", 126 | value: ownerDesc, 127 | inline: true, 128 | }, { 129 | name: "Guild", 130 | value: guildDescription, 131 | inline: true, 132 | }, { 133 | name: "VIP", 134 | value: isVIP ? `True` : `False`, 135 | inline: true, 136 | }, { 137 | name: "Blocked", 138 | value: `${number.blocked.length || `None`}`, 139 | inline: true, 140 | }, 141 | { 142 | name: "Owner Strikes", 143 | value: ownerStrikeCount.toString(), 144 | inline: true, 145 | }, 146 | { 147 | name: "Created and expires:", 148 | value: `• ${number.createdAt || "Not available"}\n• ${new Date(number.expiry)}`, 149 | inline: false, 150 | }]; 151 | 152 | this.interaction.reply({ embeds: [embed] }); 153 | 154 | // TODO: 2nd page extra info 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/commands/support/reassign.ts: -------------------------------------------------------------------------------- 1 | import { ActiveCalls, Mailbox, Numbers, Phonebook } from "@prisma/client"; 2 | import Command from "../../internals/commandProcessor"; 3 | import { parseNumber } from "../../internals/utils"; 4 | import { PermissionLevel } from "../../interfaces/commandData"; 5 | import { DMChannel } from "discord.js"; 6 | 7 | 8 | type sourceNumber = (Numbers & { 9 | incomingCalls: ActiveCalls[]; 10 | outgoingCalls: ActiveCalls[]; 11 | mailbox: Mailbox | null; 12 | phonebook: Phonebook | null; 13 | }) | null; 14 | 15 | export default class Deassign extends Command { 16 | async run(): Promise { 17 | this.interaction.deferReply(); 18 | 19 | const from = parseNumber(this.interaction.options.getString("source", true)); 20 | const rawNumber = this.interaction.options.getString("newChannel", false); 21 | 22 | const newNumber = rawNumber ? parseNumber(rawNumber) : null; 23 | const newChannel = this.interaction.options.getString("newNumber", false); 24 | 25 | // Get the source number 26 | let fromDoc: sourceNumber; 27 | if (from.length > 11) { 28 | fromDoc = await this.db.numbers.findUnique({ 29 | where: { 30 | channelID: from, 31 | }, 32 | include: { 33 | incomingCalls: true, 34 | outgoingCalls: true, 35 | mailbox: true, 36 | phonebook: true, 37 | }, 38 | }); 39 | } else { 40 | fromDoc = await this.db.numbers.findUnique({ 41 | where: { 42 | number: from, 43 | }, 44 | include: { 45 | incomingCalls: true, 46 | outgoingCalls: true, 47 | mailbox: true, 48 | phonebook: true, 49 | }, 50 | }); 51 | } 52 | 53 | if (!fromDoc) { 54 | this.interaction.editReply({ 55 | embeds: [this.client.errorEmbed("Source number not found.")], 56 | }); 57 | return; 58 | } 59 | 60 | // Check that the source is a VIP number 61 | const srcIsVIP = fromDoc.vip ? fromDoc.vip.expiry.getTime() > Date.now() : false; 62 | if (!srcIsVIP && await this.getPerms() < PermissionLevel.manager) { 63 | this.interaction.editReply({ 64 | embeds: [{ 65 | color: this.config.colors.error, 66 | title: "Not VIP", 67 | description: "That number is not currently a VIP number.", 68 | footer: { 69 | text: "Contact a boss/manager to reassign this number.", 70 | }, 71 | }], 72 | }); 73 | } 74 | 75 | // Perform checks on the new number 76 | if (newNumber) { 77 | const newDoc = await this.db.numbers.findUnique({ 78 | where: { 79 | number: newNumber, 80 | }, 81 | }); 82 | 83 | if (newDoc) { 84 | this.interaction.editReply({ 85 | embeds: [this.client.errorEmbed("New number is already in use. Confirm ownership and deassign it first.")], 86 | }); 87 | return; 88 | } 89 | 90 | fromDoc.fka.push(newNumber); 91 | } 92 | 93 | let newGuildID: string | null = null; 94 | let newUserID: string | null = null; 95 | 96 | // Perform checks on the new channel ID 97 | if (newChannel) { 98 | const newDoc = await this.db.numbers.findUnique({ 99 | where: { 100 | channelID: newChannel, 101 | }, 102 | }); 103 | 104 | if (newDoc) { 105 | this.interaction.editReply({ 106 | embeds: [this.client.errorEmbed("New channel is already in use. Confirm ownership and deassign its number first.")], 107 | }); 108 | return; 109 | } 110 | 111 | const discordChannel = await this.client.getChannel(newChannel).catch(() => null); 112 | if (!discordChannel) { 113 | this.interaction.editReply({ 114 | embeds: [this.client.errorEmbed("Couldn't find the new channel.")], 115 | }); 116 | return; 117 | } 118 | 119 | if (discordChannel.isDMBased()) newUserID = (discordChannel as DMChannel).recipientId; 120 | else newGuildID = discordChannel.guildId; 121 | } 122 | 123 | const duplicatedDBEntry = await this.db.numbers.create({ 124 | data: { 125 | number: newNumber || fromDoc.number, 126 | channelID: newChannel || fromDoc.channelID, 127 | guildID: newGuildID || fromDoc.guildID, 128 | userID: newUserID || fromDoc.userID, 129 | 130 | expiry: fromDoc.expiry, 131 | vip: fromDoc.vip, 132 | blocked: fromDoc.blocked, 133 | contacts: fromDoc.contacts, 134 | createdAt: fromDoc.createdAt, 135 | waiting: fromDoc.waiting, 136 | mentions: fromDoc.mentions, 137 | fka: { 138 | set: fromDoc.fka, 139 | }, 140 | 141 | }, 142 | }); 143 | 144 | // if (fromDoc.phonebook) { 145 | // await this.db.phonebook.({ 146 | // where: { 147 | // number: fromDoc.number, 148 | // }, 149 | // data: { 150 | 151 | // } 152 | // }) 153 | // } 154 | // TODO: WIP 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/commands/support/strike add.ts: -------------------------------------------------------------------------------- 1 | import { StrikeOffenderType } from "@prisma/client"; 2 | import { PermissionLevel } from "../../interfaces/commandData"; 3 | import Command from "../../internals/commandProcessor"; 4 | import { randomString } from "../../internals/utils"; 5 | 6 | export default class StrikeAdd extends Command { 7 | async run(): Promise { 8 | const offender = this.interaction.options.getString("offender", true); 9 | 10 | if (offender === this.interaction.user.id) { 11 | this.interaction.reply(`>fire ${this.interaction.user.id}`); 12 | return; 13 | } else if (offender === this.config.supportGuild.id) { 14 | this.interaction.reply({ 15 | embeds: [{ 16 | color: this.config.colors.error, 17 | title: "Turning against us?", 18 | description: "As if we'd would allow you to do this...", 19 | }], 20 | }); 21 | return; 22 | } 23 | 24 | const possibilities = await this.client.resolveGuildChannelNumberUser(offender); 25 | 26 | if (possibilities.user) { 27 | if (possibilities.user.bot) { 28 | this.interaction.reply({ 29 | embeds: [this.client.errorEmbed("Do not try to strike my brothers!", { title: "❌ User is a bot" })], 30 | }); 31 | return; 32 | } 33 | 34 | if (await this.client.getPerms(possibilities.user.id) as number >= PermissionLevel.customerSupport) { 35 | this.interaction.reply({ 36 | embeds: [this.client.errorEmbed("You can't get rid of someone that easily...", { title: "❌ Unfair competition" })], 37 | }); 38 | return; 39 | } 40 | } 41 | 42 | let type: StrikeOffenderType | undefined; 43 | let nameToStrike = ""; 44 | let idToStrike = ""; 45 | 46 | if (possibilities.user) { 47 | type = "USER"; 48 | nameToStrike = possibilities.user.username; 49 | idToStrike = possibilities.user.id; 50 | } else if (possibilities.guild) { 51 | type = "GUILD"; 52 | nameToStrike = possibilities.guild.name; 53 | idToStrike = possibilities.guild.id; 54 | } 55 | 56 | if (!idToStrike || !type) { 57 | this.interaction.reply({ 58 | embeds: [this.client.errorEmbed("ID could not be resolved to a number, server, user or channel.")], 59 | }); 60 | return; 61 | } 62 | 63 | const reason = this.interaction.options.getString("reason", true); 64 | await this.db.strikes.create({ 65 | data: { 66 | id: randomString(5), 67 | offender: idToStrike!, 68 | type: type!, 69 | reason, 70 | created: { 71 | by: this.interaction.user.id, 72 | }, 73 | }, 74 | }); 75 | 76 | const offenderStrikeCount = await this.db.strikes.count({ 77 | where: { 78 | offender: idToStrike, 79 | }, 80 | }) || 0; 81 | 82 | // Automatic blacklist 83 | if (offenderStrikeCount >= 3) { 84 | this.db.blacklist.create({ 85 | data: { 86 | id: idToStrike, 87 | }, 88 | }).catch(() => null); 89 | } 90 | 91 | this.interaction.reply({ 92 | embeds: [{ 93 | color: this.config.colors.success, 94 | title: "✅ User stricken", 95 | description: `**${nameToStrike}** has been stricken and now has ${offenderStrikeCount} strikes.`, 96 | }], 97 | }); 98 | 99 | // Attempt to DM the owner 100 | try { 101 | // Guild definitely exists if user doesn't at this point 102 | const userToDM = possibilities.user || await this.client.getUser(possibilities.guild!.ownerId); 103 | // We can't tell what language they speak here. 104 | await userToDM.send({ 105 | embeds: [{ 106 | color: this.config.colors.yellowbook, 107 | title: "⚠️ Warning", 108 | description: `You have received a strike against your ${type === "USER" ? "account" : `server **${possibilities.guild?.name}**`}. You now have **${offenderStrikeCount}** strike${offenderStrikeCount === 1 ? `` : `s`}`, 109 | footer: { 110 | text: offenderStrikeCount >= 3 ? `${type === "USER" ? "You" : `Your server`} have been blacklisted and may no longer use DTel` : "You may be blacklisted from using the bot if you reach 3 strikes.", 111 | }, 112 | }], 113 | }); 114 | } catch { 115 | // do nothing 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/commands/support/strike remove.ts: -------------------------------------------------------------------------------- 1 | import { Strikes } from "@prisma/client"; 2 | import Command from "../../internals/commandProcessor"; 3 | import { getUsername } from "../../internals/utils"; 4 | 5 | export default class StrikeRemove extends Command { 6 | async run(): Promise { 7 | const strikeID = this.interaction.options.getString("strike_id", true); 8 | 9 | let strike: Strikes; 10 | try { 11 | strike = await this.db.strikes.delete({ 12 | where: { 13 | id: strikeID, 14 | }, 15 | }); 16 | } catch { 17 | this.interaction.reply({ 18 | embeds: [this.client.errorEmbed("Strike could not be removed (make sure it exists).")], 19 | }); 20 | return; 21 | } 22 | 23 | const strikeCount = (await this.db.strikes.aggregate({ 24 | where: { 25 | offender: strike.offender, 26 | }, 27 | _count: { 28 | _all: true, 29 | }, 30 | }))._count._all; 31 | 32 | const offender = await this.client.resolveGuildChannelNumberUser(strike.offender); 33 | 34 | this.interaction.reply({ 35 | embeds: [{ 36 | color: this.config.colors.success, 37 | title: "✅ Strike removed", 38 | description: `Strike removed from **${(offender.user ? getUsername(offender.user) : null) || offender.guild?.name || "Unknown Offender"}** (${strike.offender})`, 39 | footer: { 40 | text: `They now have ${strikeCount} ${strikeCount > 1 ? "strikes" : "strike"}`, 41 | }, 42 | }], 43 | }); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/commands/support/uinfo.ts: -------------------------------------------------------------------------------- 1 | import { APIEmbed, User } from "discord.js"; 2 | import Command from "../../internals/commandProcessor"; 3 | import { getUsername } from "../../internals/utils"; 4 | export default class UInfo extends Command { 5 | async run(): Promise { 6 | const toFind = this.interaction.options.getString("user", true); 7 | 8 | let user: User; 9 | try { 10 | user = await this.client.getUser(toFind); 11 | } catch { 12 | await this.targetUserNotFound(); 13 | return; 14 | } 15 | 16 | const embed: APIEmbed = { 17 | color: this.config.colors.info, 18 | author: { 19 | icon_url: user.displayAvatarURL(), 20 | name: `${getUsername(user)} (${user.id})`, 21 | }, 22 | footer: { 23 | text: `Use /strikes for more information. Server management permissions indicated by red phone..`, 24 | }, 25 | }; 26 | 27 | const userAccount = await this.db.accounts.findUniqueOrThrow({ 28 | where: { 29 | id: toFind, 30 | }, 31 | include: { 32 | _count: { 33 | select: { 34 | strikes: true, 35 | }, 36 | }, 37 | }, 38 | }).catch(() => this.db.accounts.create({ 39 | data: { 40 | id: toFind, 41 | }, 42 | include: { 43 | _count: { 44 | select: { 45 | strikes: true, 46 | }, 47 | }, 48 | }, 49 | })); 50 | 51 | const blacklistEntry = await this.db.blacklist.findUnique({ 52 | where: { 53 | id: toFind, 54 | }, 55 | }); 56 | 57 | const userNumber = await this.db.numbers.findFirst({ 58 | where: { 59 | userID: toFind, 60 | }, 61 | }); 62 | 63 | embed.fields = [{ 64 | name: "Blacklisted", 65 | value: blacklistEntry ? "True" : "False", 66 | inline: true, 67 | }, { 68 | name: "Strikes", 69 | value: userAccount?._count.strikes ? userAccount?._count.strikes.toString() : "0", 70 | inline: true, 71 | }, { 72 | name: "DM Number", 73 | value: userNumber ? `\`${userNumber.number}\`` : `None`, 74 | inline: true, 75 | }, { 76 | name: "Balance", 77 | value: `${this.config.dtsEmoji} ${userAccount.balance}`, 78 | inline: true, 79 | }, { 80 | name: "VIP Months", 81 | value: userAccount.vipMonthsRemaining.toString(), 82 | inline: true, 83 | }]; 84 | 85 | this.interaction.reply({ embeds: [embed] }); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/config/config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | // BOT MAINTENANCE 3 | maintainers: [ 4 | "207484517898780672", // Austin 5 | "137589790538334208", // Sun 6 | "124989722668957700", // Mitchell 7 | ], 8 | devMode: false, 9 | devOnlyMode: false, 10 | shardCount: 7, 11 | 12 | // NUMBER ALIASES 13 | aliasNumbers: { 14 | "*611": "08007877678", 15 | }, 16 | 17 | // SETTINGS 18 | prefix: ">", 19 | dailies: { // credits earned from using >daily 20 | boss: 300, 21 | manager: 250, 22 | customerSupport: 200, 23 | contributor: 180, 24 | donator: 120, 25 | default: 80, 26 | }, 27 | lotteryCost: 5, // credits per lottery entry 28 | messageCost: 2, // cost for messages sent with >message 29 | pickupBonus: 25, // *611 calls 30 | promoteCost: 100, 31 | renewalRate: 500, 32 | normalTransferRate: 0.85, // 15% fee 33 | vipTransferRate: 0.95, // 5% fee 34 | minTransfer: 50, 35 | maxNumbers: 3, 36 | promoteTimeout: 7, // days 37 | 38 | // EMOTES - don't change order 39 | callPhones: { 40 | default: "<:DTelPhone:709100612935221289>", 41 | donator: "<:GoldPhone:709101494242246657>", 42 | support: "<:GreenPhone:709101494556819507>", 43 | contributor: "<:RedPhone:742293638931021904>", 44 | admin: "📞", 45 | }, 46 | // dtsEmoji: "<:DTS:668551813317787659>", 47 | // dtsEmojiID: "668551813317787659", 48 | // dtsEmojiName: "DTS", 49 | dtsEmoji: "<:DTS:668551813317787659>", 50 | 51 | // IDs 52 | // TODO: Return IDs to their original values 53 | supportGuild: { 54 | id: "281815661317980160", 55 | channels: { 56 | officesCategory: "355890256786227210", 57 | 58 | announcement: "281816926144167946", 59 | logs: "282253502779228160", 60 | badLogs: "377945714166202368", 61 | promote: "398569181097754624", 62 | support: "281816105289515008", 63 | management: "326075875466412033", 64 | }, 65 | roles: { 66 | boss: "281815725365264385", 67 | manager: "284443515516354560", 68 | customerSupport: "281815839936741377", 69 | contributor: "394214123547918336", 70 | donator: "324578460183822337", 71 | }, 72 | supportNumber: "08007877678", // 0800SUPPORT 73 | }, 74 | discoin: { 75 | guideLink: "https://discoin.gitbook.io/docs/users-guide", 76 | guildID: "347859709711089674", 77 | apiEndpoint: "https://dash.discoin.zws.im/#", 78 | }, 79 | 80 | // LINKS 81 | applyLink: "https://discordtel.typeform.com/to/wHjMpX", 82 | botInvite: "https://discord.com/api/oauth2/authorize?client_id=377609965554237453&permissions=274877990912&scope=bot%20applications.commands", 83 | githubLink: "https://github.com/DTel-HQ/dtel", 84 | guidelink: "https://dtel.austinhuang.me/en/latest/Customer-Support-Guide/", 85 | guildInvite: "https://discord.gg/DcayXMc", 86 | paymentLink: "https://dtel.austinhuang.me/en/latest/Payment/", 87 | siteLink: "https://dtel.austinhuang.me", 88 | suggestLink: "https://feedback.austinhuang.me/", 89 | vipLink: "https://dtel.austinhuang.me/en/latest/VIP-Numbers/", 90 | voteLink: "https://dtel.austinhuang.me/en/latest/Payment/#voting-for-us-on-listings", 91 | 92 | // Embed colors 93 | colors: { 94 | contacts: 0x50C878, // green 95 | error: 0xff3333, // red 96 | info: 0x3498d8, // blue 97 | lottery: 0x80002a, // red 98 | receipt: 0xe1e1e1, // white (duh) 99 | success: 0x47d147, // green 100 | yellowbook: 0xe6e600, // yellow 101 | vip: 0xffc61a, // gold 102 | }, 103 | 104 | // Winston (logger) settings 105 | winston: { 106 | consoleLevel: "silly", 107 | fileLevel: "silly", 108 | }, 109 | }; 110 | -------------------------------------------------------------------------------- /src/database/db.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | import { Collection } from "@discordjs/collection"; 3 | import { winston } from "../dtel"; 4 | const prisma = new PrismaClient(); 5 | 6 | const blacklistCache = new Collection(); 7 | 8 | const populateBlacklistCache = () => { 9 | prisma.blacklist.findMany().then(allBlacklist => { 10 | allBlacklist.map(m => blacklistCache.set(m.id, m)); 11 | }); 12 | }; 13 | 14 | prisma.$use(async(params, next) => { 15 | // Check incoming query type 16 | if (params.action === "deleteMany" || params.action === "updateMany") { 17 | if (params.args.where === undefined && params.model !== "Votes") { 18 | winston.error("INCREDIBLY UNSAFE QUERY DETECTED!"); 19 | return; 20 | } 21 | } 22 | return next(params); 23 | }); 24 | 25 | export { prisma as db, blacklistCache, populateBlacklistCache }; 26 | -------------------------------------------------------------------------------- /src/dtel.ts: -------------------------------------------------------------------------------- 1 | import { Guild, Interaction, Message, Options, PartialMessage, Partials, Typing } from "discord.js"; 2 | import i18next from "i18next"; 3 | 4 | import Client from "./internals/client"; 5 | import Console from "./internals/console"; 6 | import i18nData from "./internationalization/i18n"; 7 | 8 | import InteractionEvent from "./events/interactionCreate"; 9 | import MessageCreateEvent from "./events/messageCreate"; 10 | import MessageDeleteEvent from "./events/messageDelete"; 11 | import MessageUpdateEvent from "./events/messageUpdate"; 12 | import GuildCreateEvent from "./events/guildCreate"; 13 | import GuildDeleteEvent from "./events/guildDelete"; 14 | import ReadyEvent from "./events/ready"; 15 | import TypingStartEvent from "./events/typingStart"; 16 | 17 | import { populateBlacklistCache } from "./database/db"; 18 | import SharderMessageEvent from "./events/sharderMessage"; 19 | import { upperFirst } from "./internals/utils"; 20 | 21 | populateBlacklistCache(); 22 | 23 | const winston = Console(`Shard ${process.env.SHARDS}`); 24 | 25 | i18next.init({ 26 | // debug: config.devMode, 27 | fallbackLng: "en", 28 | preload: ["en-US"], 29 | 30 | returnObjects: true, 31 | resources: i18nData, 32 | }); 33 | i18next.services.formatter?.add("upperFirst", value => upperFirst(value)); 34 | 35 | const client = new Client({ 36 | intents: [ 37 | "Guilds", 38 | "GuildVoiceStates", 39 | "GuildMessages", 40 | "GuildMessageTyping", 41 | "GuildEmojisAndStickers", 42 | "DirectMessages", 43 | 44 | // Privileged 45 | "MessageContent", 46 | ], 47 | partials: [Partials.Channel], 48 | makeCache: Options.cacheWithLimits({ 49 | ...Options.DefaultMakeCacheSettings, 50 | MessageManager: 1, 51 | GuildInviteManager: 0, 52 | GuildEmojiManager: 0, 53 | GuildStickerManager: 0, 54 | UserManager: 1000, 55 | }), 56 | }); 57 | 58 | client.on("ready", () => ReadyEvent(client)); 59 | 60 | client.on("messageCreate", (msg: Message) => MessageCreateEvent(client, msg)); 61 | client.on("messageUpdate", (before: Message | PartialMessage, after: Message | PartialMessage) => MessageUpdateEvent(client, before as Message, after as Message)); 62 | client.on("messageDelete", (msg: Message | PartialMessage) => MessageDeleteEvent(client, msg as Message)); 63 | client.on("interactionCreate", (interaction: Interaction) => InteractionEvent(client, interaction)); 64 | client.on("guildCreate", (guild: Guild) => GuildCreateEvent(client, guild)); 65 | client.on("guildDelete", (guild: Guild) => GuildDeleteEvent(client, guild)); 66 | 67 | client.on("typingStart", (typing: Typing) => TypingStartEvent(client, typing)); 68 | 69 | 70 | process.on("message", msg => SharderMessageEvent(client, msg as Record)); 71 | 72 | client.login(process.env.TOKEN); 73 | 74 | export { client, winston }; 75 | 76 | -------------------------------------------------------------------------------- /src/embed.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // This embed is used in DTel HQ (the support server)'s #info channel. 3 | const embed = { 4 | embed: { 5 | color: 0x3498d8, 6 | fields: [ 7 | { 8 | name: ":book: **DTel Server Information**", 9 | value: "For help with using commands, use `>help`.\nThe invite for this server is https://discord.gg/RN7pxrB. \nThe documentation for DTel is located at https://dtel.austinhuang.me/en/latest/. \nYou can get support by calling `*611`, or you can raise any concerns with the devs by sending us an email.\nIn addition, you can apply to become Customer Support [here](https://dtel.typeform.com/to/wHjMpX).", 10 | }, 11 | { 12 | name: ":telephone_receiver: **Getting a number**", 13 | value: "Type `>wizard` in any server that you have the `Manage Server` permission in (or DMs with the bot) and follow the steps.", 14 | }, 15 | { 16 | name: "**Rules**", 17 | value: ":one: **Don't** advertise your server here, please keep spam in #trash-bin, and don't post anything inappropriate.\n:two: **Don't** call *611 from #public-payphone (it won't let you anyway.)\n:three: **Do not ask for staff**: We will hire people when we need to. In addition, asking about your application is an instant denial.\n:four: **Respect staff.**\n:five: **Do not** ping bosses or other staff directly for support. Instead, >dial *611 in any DTel-enabled channel.", 18 | }, 19 | { 20 | name: ":inbox_tray: **Inviting the bot**", 21 | value: `Use \`>invite\` or click this button: [<:dl:382568980218511361>](${config.botInvite})`, 22 | }, 23 | { 24 | name: ":question: **Who's who?**", 25 | value: "**Regular users** have the <:DTelPhone:310817969498226718> emoji.\n**Customer Support** have the <:GreenPhone:709101494556819507> emoji.\n**Donators** have the <:GoldPhone:709101494242246657> emoji.", 26 | }, 27 | ], 28 | footer: { 29 | text: "DTel Headquarters", 30 | icon_url: client.user.avatarURL({ format: "png" }), 31 | }, 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /src/events/allShardsReady.ts: -------------------------------------------------------------------------------- 1 | import { winston } from "../dtel"; 2 | import CallClient from "../internals/callClient"; 3 | import DTelClient from "../internals/client"; 4 | 5 | export default async(client: DTelClient): Promise => { 6 | winston.info("Received the all clear! Starting calls..."); 7 | 8 | client.allShardsSpawned = true; 9 | 10 | // const allCalls = await client.db.activeCalls.findMany({ 11 | // include: { 12 | // to: { 13 | // include: { 14 | // guild: true, 15 | // }, 16 | // }, 17 | // from: { 18 | // include: { 19 | // guild: true, 20 | // }, 21 | // }, 22 | // }, 23 | // }); 24 | 25 | // for (const call of allCalls) { 26 | // const doc = await CallClient.byID(client, { 27 | // doc: call, 28 | // side: "to", 29 | // }); 30 | 31 | // client.calls.set(call.id, doc); 32 | // } 33 | 34 | if (process.env.SHARDS == "0") { 35 | require("../internals/jobs"); 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /src/events/guildCreate.ts: -------------------------------------------------------------------------------- 1 | import { Guild } from "discord.js"; 2 | import DTelClient from "../internals/client"; 3 | 4 | export default async(client: DTelClient, guild: Guild): Promise => { 5 | client.log(`📥 Joined guild \`${guild.name}\` (\`${guild.id}\`). Currently in \`${client.guilds.cache.size}\` servers on Shard ${process.env.SHARDS} and \`${await client.getGuildCount()}\` servers total.`); 6 | }; 7 | -------------------------------------------------------------------------------- /src/events/guildDelete.ts: -------------------------------------------------------------------------------- 1 | import { Guild } from "discord.js"; 2 | import DTelClient from "../internals/client"; 3 | import { winston } from "../dtel"; 4 | 5 | export default async(client: DTelClient, guild: Guild): Promise => { 6 | if (!guild.available) { 7 | winston.error(`Guild ${guild.name} (${guild.id}) is unavailable. Ignoring Guild Delete event.`); 8 | } 9 | client.log(`📤 Left guild \`${guild.name}\` (\`${guild.id}\`). Currently in \`${client.guilds.cache.size}\` servers on Shard ${process.env.SHARDS} and \`${await client.getGuildCount()}\` servers total.`); 10 | }; 11 | -------------------------------------------------------------------------------- /src/events/interactionCreate.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import { 3 | CommandInteraction, 4 | MessageComponentInteraction, 5 | ModalSubmitInteraction, 6 | Interaction, 7 | SnowflakeUtil, 8 | InteractionType, 9 | PermissionsBitField, 10 | ChatInputCommandInteraction, 11 | ApplicationCommandOptionType, 12 | } from "discord.js"; 13 | import Commands from "../config/commands"; 14 | import Command, { CommandType, PermissionLevel, SubcommandData } from "../interfaces/commandData"; 15 | import Constructable from "../interfaces/constructable"; 16 | import DTelClient from "../internals/client"; 17 | import Processor, { ChannelBasedInteraction } from "../internals/processor"; 18 | import i18n, { getFixedT } from "i18next"; 19 | import { winston } from "../dtel"; 20 | import config from "../config/config"; 21 | import { blacklistCache } from "../database/db"; 22 | 23 | export default async(client: DTelClient, _interaction: Interaction): Promise => { 24 | const interaction = _interaction as CommandInteraction|MessageComponentInteraction|ModalSubmitInteraction; 25 | 26 | const t = getFixedT(interaction.locale, "events.interactionCreate"); 27 | 28 | if (blacklistCache.get(interaction.user.id)) { 29 | interaction.reply(i18n.t("errors.blacklisted", { 30 | lng: interaction.locale, 31 | })); 32 | return; 33 | } 34 | const call = client.calls.find(c => c.from.channelID === interaction.channelId || c.to.channelID === interaction.channelId); 35 | 36 | let commandName: string; 37 | let toRunPath: string; 38 | let commandData: Command; 39 | let permissionLevel: PermissionLevel = PermissionLevel.none; 40 | 41 | switch (interaction.type) { 42 | case InteractionType.ApplicationCommand: { 43 | const typedInteraction = interaction as ChatInputCommandInteraction; 44 | 45 | commandName = typedInteraction.commandName; 46 | const cmd = Commands.find(c => c.name === commandName); 47 | if (!cmd) throw new Error(`Could not find command data for command ${commandName}`); 48 | commandData = cmd; 49 | 50 | if (commandData.notExecutableInCall && call) { 51 | interaction.reply({ 52 | embeds: [client.errorEmbed(i18n.t("errors.notExecutableInCall"))], 53 | }); 54 | return; 55 | } 56 | 57 | toRunPath = `${__dirname}/../commands`; 58 | 59 | switch (commandData.useType) { 60 | case CommandType.standard: { 61 | toRunPath += "/standard"; 62 | break; 63 | } 64 | case CommandType.call: { 65 | toRunPath += "/call"; 66 | break; 67 | } 68 | case CommandType.customerSupport: { 69 | toRunPath += "/support"; 70 | break; 71 | } 72 | case CommandType.maintainer: { 73 | toRunPath += "/maintainer"; 74 | break; 75 | } 76 | } 77 | 78 | const subCommand = typedInteraction.options.getSubcommand(false); 79 | if (subCommand) { 80 | const subData = commandData.options?.find(o => o.name === subCommand) as SubcommandData | null; 81 | if (!subData) throw new Error(); 82 | 83 | commandName = `${commandName} ${subCommand}`; 84 | permissionLevel = subData.permissionLevel; 85 | } else { 86 | permissionLevel = commandData.permissionLevel; 87 | } 88 | 89 | toRunPath += `/${commandName}`; 90 | 91 | break; 92 | } 93 | case InteractionType.MessageComponent: 94 | case InteractionType.ModalSubmit: { 95 | const typedInteraction = interaction as MessageComponentInteraction|ModalSubmitInteraction; 96 | 97 | if (interaction.type === InteractionType.ModalSubmit && interaction.message?.interaction && interaction.message?.interaction?.user.id != interaction.user.id) { 98 | interaction.reply(t("errors.wrongUser")); 99 | return; 100 | } 101 | 102 | // Interaction expiry after 2 minutes 103 | if (typedInteraction.message && (Date.now() - SnowflakeUtil.timestampFrom(typedInteraction.message.id)) > (2 * 60 * 1000)) { 104 | interaction.reply({ 105 | content: i18n.t("events.interactionCreate.errors.expiredInteraction", { lng: interaction.locale }), 106 | ephemeral: true, 107 | }); 108 | 109 | return; 110 | } 111 | 112 | const split = typedInteraction.customId.split("-"); 113 | if (split.length < 2) { 114 | interaction.reply({ 115 | embeds: [client.errorEmbed(i18n.t("errors.unexpected", { lng: interaction.locale }))], 116 | ephemeral: true, 117 | }); 118 | winston.error(`Message component interaction custom ID not valid.`); 119 | return; 120 | } 121 | 122 | commandName = split[0]; 123 | let interactionName: string = split.slice(1, split.length).join("-"); 124 | 125 | if (commandName.startsWith("dtelnoreg")) return; 126 | 127 | const cmd = Commands.find(c => c.name === commandName); 128 | if (!cmd) throw new Error(`Could not find command data for command ${commandName}`); 129 | commandData = cmd; 130 | 131 | toRunPath = `${__dirname}/../interactions/${commandName}`; 132 | 133 | const subCommand = cmd.options?.filter(o => o.type === ApplicationCommandOptionType.Subcommand) as SubcommandData[] | null; 134 | 135 | if (subCommand && subCommand.length > 0) { 136 | commandName = `${split[0]} ${split[1]}`; 137 | interactionName = split[2]; 138 | 139 | permissionLevel = subCommand.find(c => c.name == split[1])?.permissionLevel || PermissionLevel.none; 140 | toRunPath += `/${split[1]}`; 141 | } else { 142 | permissionLevel = commandData.permissionLevel; 143 | } 144 | 145 | const paramsToSend: string[] = []; 146 | 147 | if (interactionName.includes("-params-")) { 148 | const paramSplit = interactionName.split("-params-"); 149 | interactionName = paramSplit[0]; 150 | const params = paramSplit[1]; 151 | 152 | if (params) { 153 | const parsedParams = params.split("-"); 154 | for (let i = 0; i < parsedParams.length; i++) { 155 | paramsToSend.push(parsedParams[i]); 156 | } 157 | } 158 | } 159 | 160 | commandData.params = paramsToSend; 161 | 162 | toRunPath += `/${interactionName}`; 163 | } 164 | } 165 | 166 | commandData = commandData!; // It definitely exists if it got this far 167 | if (commandData.useType === CommandType.call && !call) { 168 | interaction.reply({ 169 | embeds: [client.errorEmbed(i18n.t("errors.onlyExecutableInCall"))], 170 | }); 171 | return; 172 | } 173 | 174 | let processorFile: Constructable>; 175 | try { 176 | if (client.config.devMode) { 177 | delete require.cache[require.resolve(toRunPath!)]; 178 | } 179 | processorFile = require(toRunPath!).default; 180 | if (!processorFile) throw new Error("Processor file not found"); 181 | } catch (e) { 182 | if (config.devMode) { 183 | console.error(e); 184 | } 185 | 186 | client.winston.error(`Cannot process interaction ${toRunPath.split("/").pop()} for/from command: ${commandName!}`); 187 | interaction.reply(":x: Interaction not yet implemented."); 188 | return; 189 | } 190 | 191 | const processorClass = new processorFile(client, interaction, commandData); 192 | try { 193 | const userPermissions = await client.getPerms(interaction.user.id); 194 | // Bypass checks if ran by a maintainer 195 | if (userPermissions != PermissionLevel.maintainer) { 196 | switch (permissionLevel) { 197 | case PermissionLevel.maintainer: { 198 | if (userPermissions != PermissionLevel.maintainer) { 199 | processorClass.notMaintainer(); 200 | return; 201 | } 202 | break; 203 | } 204 | case PermissionLevel.customerSupport: { 205 | if (userPermissions as number < PermissionLevel.customerSupport) { 206 | processorClass.permCheckFail(); 207 | return; 208 | } 209 | if (interaction.guildId !== config.supportGuild.id && !config.devMode) { 210 | processorClass.notInSupportGuild(); 211 | return; 212 | } 213 | break; 214 | } 215 | case PermissionLevel.serverAdmin: { 216 | if (!(interaction.member!.permissions as PermissionsBitField).has(PermissionsBitField.Flags.ManageGuild)) { 217 | processorClass.permCheckFail(); 218 | return; 219 | } 220 | break; 221 | } 222 | } 223 | } 224 | 225 | processorClass._run(); 226 | } catch (_err) { 227 | const err = _err as Error; 228 | winston.error(`Error occurred whilst executing interaction for/from command: ${commandName!}`, err.stack); 229 | interaction.reply({ 230 | embeds: [client.errorEmbed(i18n.t("errors.unexpected", { lng: interaction.locale }))], 231 | }); 232 | } 233 | }; 234 | 235 | -------------------------------------------------------------------------------- /src/events/messageCreate.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder, Message } from "discord.js"; 2 | import { blacklistCache } from "../database/db"; 3 | import DTelClient from "../internals/client"; 4 | import config from "../config/config"; 5 | 6 | export default async(client: DTelClient, message: Message): Promise => { 7 | if (message.author.id === client.user!.id || blacklistCache.get(message.author.id)) return; // Don't cause loopback & ignore blacklist 8 | 9 | const call = client.calls.find(c => c.to.channelID === message.channel.id || c.from.channelID === message.channel.id); 10 | if (!call) { 11 | if (message.content.startsWith(">ping") || message.content.startsWith(">call") || message.content.startsWith(">dial") || message.content.startsWith(">rdial") || message.content.startsWith(">daily")) { 12 | const embed = new EmbedBuilder() 13 | .setColor(config.colors.info) 14 | .setTitle("DTel has moved to Slash Commands!") 15 | .setDescription(`Type \`/\` in the chat to view the available commands. If you need help, please join our [support server](${config.guildInvite})!`); 16 | message.channel.send({ 17 | embeds: [embed], 18 | }).catch(() => null); 19 | } 20 | return; 21 | } // We don't need to handle messages we have nothing to do with 22 | 23 | call.messageCreate(message); 24 | }; 25 | -------------------------------------------------------------------------------- /src/events/messageDelete.ts: -------------------------------------------------------------------------------- 1 | import { Message } from "discord.js"; 2 | import { blacklistCache } from "../database/db"; 3 | import DTelClient from "../internals/client"; 4 | 5 | export default async(client: DTelClient, message: Message): Promise => { 6 | if (!message.author) return; 7 | if (message.author.id === client.user!.id || blacklistCache.get(message.author.id)) return; // Don't cause loopback & ignore blacklist 8 | 9 | const call = client.calls.find(c => c.to.channelID === message.channel.id || c.from.channelID === message.channel.id); 10 | if (!call) return; // We don't need to handle messages we have nothing to do with 11 | 12 | call.messageDelete(message); 13 | }; 14 | -------------------------------------------------------------------------------- /src/events/messageUpdate.ts: -------------------------------------------------------------------------------- 1 | import { Message } from "discord.js"; 2 | import { blacklistCache } from "../database/db"; 3 | import DTelClient from "../internals/client"; 4 | 5 | export default async(client: DTelClient, before: Message, after: Message): Promise => { 6 | if (!after.author) return; 7 | if (after.author.id === client.user!.id || blacklistCache.get(after.author.id)) return; // Don't cause loopback & ignore blacklist 8 | 9 | const call = client.calls.find(c => c.to.channelID === after.channel.id || c.from.channelID === after.channel.id); 10 | if (!call) return; // We don't need to handle messages we have nothing to do with 11 | 12 | call.messageUpdate(before, after); 13 | }; 14 | -------------------------------------------------------------------------------- /src/events/ready.ts: -------------------------------------------------------------------------------- 1 | import DTelClient from "../internals/client"; 2 | import Commands from "../config/commands"; 3 | import config from "../config/config"; 4 | 5 | export default async(client: DTelClient): Promise => { 6 | client.winston.info(`Ready!`); 7 | client.winston.info(`Logged in as ${client.user!.tag}`); 8 | 9 | client.user!.setActivity({ 10 | name: `[${process.env.SHARDS}] Starting up...`, 11 | }); 12 | 13 | // client.application.commands.set(client.commands); 14 | if (process.env.SHARDS === "0") { 15 | client.application!.commands.set(Commands); 16 | if (client.application.installParams) config.botInvite = client.generateInvite((await client.application.fetch()).installParams!); 17 | 18 | // client.application!.commands.set(Commands, "385862448747511812"); 19 | // client.application!.commands.set(Commands, "398980667553349649"); 20 | } 21 | 22 | client.shard!.send({ msg: "ready", shardID: Number(process.env.SHARDS) }); 23 | }; 24 | -------------------------------------------------------------------------------- /src/events/sharderMessage.ts: -------------------------------------------------------------------------------- 1 | import { ActiveCalls } from "@prisma/client"; 2 | import { TextBasedChannel } from "discord.js"; 3 | import { winston } from "../dtel"; 4 | import CallClient, { CallsWithNumbers } from "../internals/callClient"; 5 | import DTelClient from "../internals/client"; 6 | import allShardsReady from "./allShardsReady"; 7 | 8 | export default async(client: DTelClient, msg: Record): Promise => { 9 | switch (msg.msg) { 10 | case "callInitiated": { 11 | const callObject = JSON.parse(msg.callDBObject as string) as CallsWithNumbers; 12 | let channel: TextBasedChannel; 13 | try { 14 | channel = (await client.channels.fetch(callObject.to.channelID)) as TextBasedChannel; 15 | if (!channel) throw new Error(); 16 | } catch { 17 | return; // We clearly don't have the channel on this shard 18 | } 19 | 20 | // From here, we can assume we *do* have the channel and can handle this call 21 | const callClient = new CallClient(client, undefined, callObject); 22 | client.calls.set(callClient.id, callClient); 23 | break; 24 | } 25 | case "callRepropagate": { 26 | const messageObject = JSON.parse(msg.callDBObject as string) as callRepropagate; 27 | const call = client.calls.get(messageObject.callID); 28 | 29 | if (!call) { 30 | winston.error(`Call repropagation for ID ${messageObject.callID} failed: Call not found`); 31 | return; 32 | } 33 | 34 | call.handleReprop(messageObject.call); 35 | break; 36 | } 37 | case "callEnded": { 38 | const typed = msg as unknown as callEnded; 39 | client.calls.delete(typed.callID); 40 | break; 41 | } 42 | 43 | case "allShardsSpawned": { 44 | allShardsReady(client); 45 | break; 46 | } 47 | 48 | case "resume": { 49 | if (msg.shardID === Number(process.env.SHARDS)) { 50 | allShardsReady(client); 51 | winston.info("Received all clear for resume! Starting calls..."); 52 | } 53 | break; 54 | } 55 | 56 | case "callResume": { 57 | const callDoc = msg.callDoc as CallsWithNumbers; 58 | 59 | if (msg.fromShard != Number(process.env.SHARDS) && msg.toShard != Number(process.env.SHARDS)) { 60 | return; 61 | } else if (msg.fromShard === msg.toShard) { 62 | if (client.calls.get(callDoc.id)) { 63 | winston.info(`Call ${callDoc.id} already exists on this shard, ignoring.`); 64 | return; 65 | } 66 | } 67 | 68 | winston.info(`Recovering call ID: ${callDoc.id}`); 69 | 70 | const call = await CallClient.byID(client, { 71 | side: msg.fromShard === Number(process.env.SHARDS) ? "from" : "to", 72 | doc: callDoc, 73 | id: callDoc.id, 74 | }); 75 | client.calls.set(call.id, call); 76 | 77 | break; 78 | } 79 | } 80 | }; 81 | 82 | interface callBase { 83 | msg: string, 84 | callID: string, 85 | } 86 | 87 | interface callRepropagate extends callBase { 88 | call: ActiveCalls 89 | } 90 | 91 | interface callEnded extends callBase { 92 | endedBy: string, 93 | } 94 | -------------------------------------------------------------------------------- /src/events/typingStart.ts: -------------------------------------------------------------------------------- 1 | import { Typing } from "discord.js"; 2 | import DTelClient from "../internals/client"; 3 | 4 | export default (client: DTelClient, typing: Typing): void => { 5 | if (typing.user.bot) return; 6 | const call = client.calls.find(c => c.from.channelID === typing.channel.id || c.to.channelID === typing.channel.id); 7 | if (!call) return; 8 | 9 | call.typingStart(typing); 10 | }; 11 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { APITextChannel, REST, ShardClientUtil, ShardingManager } from "discord.js"; 2 | import auth from "./config/auth"; 3 | import config from "./config/config"; 4 | import Console from "./internals/console"; 5 | import { PrismaClient } from "@prisma/client"; 6 | 7 | // Main IPC process 8 | const sharder = new ShardingManager(`${__dirname}/dtel.js`, { 9 | totalShards: config.shardCount, 10 | token: auth.discord.token, 11 | }); 12 | 13 | const winston = Console("Master"); 14 | 15 | const shardsReady: number[] = []; 16 | sharder.on("shardCreate", shard => { 17 | winston.info(`Spawned shard ID: ${shard.id}`); 18 | 19 | shard.on("message", message => { 20 | switch (message.msg) { 21 | case "callEnded": 22 | case "callInitiated": { 23 | sharder.broadcast(message); 24 | break; 25 | } 26 | 27 | case "ready": { 28 | if (shardsReady.includes(message.shardID)) { 29 | winston.info(`Shard ${message.shardID} is recovering from an issue...`); 30 | sharder.broadcast({ msg: "resume", shardID: message.shardID }); 31 | } else { 32 | shardsReady.push(message.shardID); 33 | 34 | if (shardsReady.length === config.shardCount) { 35 | winston.info("All shards spawned, starting calls and jobs..."); 36 | sharder.broadcast({ msg: "allShardsSpawned" }); 37 | 38 | allShardsReady(); 39 | } 40 | } 41 | 42 | break; 43 | } 44 | } 45 | }); 46 | }); 47 | 48 | const rest = new REST({ version: "9" }).setToken(auth.discord.token); 49 | 50 | const shardIdForChannelId = async(id: string) => { 51 | if (config.shardCount == 1) return 0; 52 | const channelObject = await rest.get(`/channels/${id}`) as APITextChannel; 53 | 54 | if (!channelObject.guild_id) return 0; 55 | 56 | return ShardClientUtil.shardIdForGuildId(channelObject.guild_id, config.shardCount); 57 | }; 58 | 59 | const allShardsReady = async(): Promise => { 60 | const db = new PrismaClient(); 61 | 62 | const allCalls = await db.activeCalls.findMany({ 63 | include: { 64 | to: { 65 | include: { 66 | guild: true, 67 | }, 68 | }, 69 | from: { 70 | include: { 71 | guild: true, 72 | }, 73 | }, 74 | }, 75 | }); 76 | 77 | 78 | for (const call of allCalls) { 79 | if (!call.from || !call.to) continue; 80 | console.log(`Processing ${call.id} on sharder`); 81 | 82 | let fromShard: number, toShard: number; 83 | 84 | try { 85 | fromShard = await shardIdForChannelId(call.from.channelID); // Primary shard 86 | 87 | toShard = await shardIdForChannelId(call.to.channelID); // Secondary shard 88 | } catch { 89 | console.log(`Failed to get shard for ${call.id}. It needs to be ended.`); 90 | continue; 91 | } 92 | 93 | sharder.broadcast({ 94 | msg: "callResume", 95 | callDoc: call, 96 | 97 | fromShard, 98 | toShard, 99 | }); 100 | } 101 | 102 | db.$disconnect(); 103 | }; 104 | 105 | winston.info("Spawning shards..."); 106 | sharder.spawn(); 107 | -------------------------------------------------------------------------------- /src/interactions/call/233-open.ts: -------------------------------------------------------------------------------- 1 | import { ActionRowBuilder, ButtonInteraction, StringSelectMenuBuilder, StringSelectMenuOptionBuilder } from "discord.js"; 2 | import MessageComponentProcessor from "../../internals/componentProcessor"; 3 | 4 | export default class TwoThreeThreeOpenModalButton extends MessageComponentProcessor { 5 | async run(): Promise { 6 | const monthSelectorOptions: StringSelectMenuOptionBuilder[] = []; 7 | // For up to 11 months 8 | for (let i = 1; i <= 11; i++) { 9 | const cost = this.config.renewalRate * i; 10 | if (cost > this.account!.balance) break; 11 | 12 | const option = new StringSelectMenuOptionBuilder() 13 | .setLabel(this.genericT("month", { 14 | count: i, 15 | lng: this.interaction.locale, 16 | })) 17 | .setDescription(this.genericT("credit", { 18 | count: cost, 19 | lng: this.interaction.locale, 20 | })) 21 | .setValue(`m-${i}`); 22 | 23 | monthSelectorOptions.push(option); 24 | } 25 | // For up to 3 years 26 | for (let i = 1; i <= 3; i++) { 27 | const cost = this.config.renewalRate * i * 12; 28 | if (cost > this.account!.balance) break; 29 | 30 | const option = new StringSelectMenuOptionBuilder() 31 | .setLabel(this.genericT("year", { 32 | count: i, 33 | lng: this.interaction.locale, 34 | })) 35 | .setDescription(this.genericT("credit", { 36 | count: cost, 37 | lng: this.interaction.locale, 38 | })) 39 | .setValue(`y-${i}`); 40 | 41 | monthSelectorOptions.push(option); 42 | } 43 | 44 | 45 | this.interaction.reply({ 46 | ephemeral: true, 47 | components: [new ActionRowBuilder().addComponents([ 48 | new StringSelectMenuBuilder() 49 | .setCustomId("call-233-renew") 50 | .setPlaceholder(this.t("monthsToRenewLabel")) 51 | .addOptions(monthSelectorOptions), 52 | ])], 53 | }); 54 | 55 | // this.interaction.showModal(modal); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/interactions/call/233-renew.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { SelectMenuInteraction } from "discord.js"; 3 | import MessageComponentProcessor from "../../internals/componentProcessor"; 4 | 5 | export default class TwoThreeThreeRenewModal extends MessageComponentProcessor { 6 | async run() { 7 | const selected = this.interaction.values[0]; // eg m-1, m-2, y-1 8 | 9 | const split = selected.split("-"); 10 | const monthYear = split[0]; 11 | const amount = Number(split[1]); 12 | 13 | // Calculate cost to renew 14 | const cost = this.config.renewalRate * amount * (monthYear === "y" ? 12 : 1); 15 | 16 | if (cost > this.account!.balance) { 17 | this.interaction.reply({ 18 | embeds: [this.client.errorEmbed(this.t("twoThreeThree.cantAffordAfterSelector"))], 19 | }); 20 | return; 21 | } 22 | 23 | this.account!.balance -= cost; 24 | 25 | await this.db.accounts.update({ 26 | where: { 27 | id: this.interaction.user.id, 28 | }, 29 | data: { 30 | balance: this.account!.balance, 31 | }, 32 | }); 33 | 34 | let newExpiry = dayjs(this.number!.expiry).add(amount, monthYear === "y" ? "year" : "month"); 35 | if (this.number!.expiry < new Date()) newExpiry = dayjs().add(amount, monthYear === "y" ? "year" : "month"); 36 | 37 | await this.db.numbers.update({ 38 | where: { 39 | number: this.number!.number, 40 | }, 41 | data: { 42 | expiry: newExpiry.toDate(), 43 | }, 44 | }); 45 | 46 | const amountOfTimeDisplay = this.genericT(monthYear === "m" ? "month" : "year", { count: amount }); 47 | 48 | this.interaction.reply({ 49 | embeds: [{ 50 | color: this.config.colors.receipt, 51 | author: { 52 | name: this.interaction.user.username, 53 | icon_url: this.interaction.user.displayAvatarURL(), 54 | }, 55 | 56 | ...this.t("twoThreeThree.receiptEmbed", { 57 | amountOfTime: amountOfTimeDisplay, 58 | number: this.number!.number, 59 | expiration: newExpiry.format("YYYY-MM-DD"), 60 | balance: this.account!.balance, 61 | }), 62 | }], 63 | }); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/interactions/call/411-manage-add-modal.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder } from "discord.js"; 2 | import ModalProcessor from "../../internals/modalProcessor"; 3 | 4 | 5 | export default class Call411EditAddModal extends ModalProcessor { 6 | async run(): Promise { 7 | this.interaction.deferUpdate(); 8 | 9 | const description = this.interaction.fields.getTextInputValue("description"); 10 | 11 | await this.db.phonebook.create({ 12 | data: { 13 | number: this.number!.number, 14 | description, 15 | }, 16 | }); 17 | 18 | const embed = new EmbedBuilder() 19 | .setColor(this.config.colors.success) 20 | .setDescription(`Added your number to the Yellowbook.`) 21 | .setTitle("✅ Success!") 22 | .setFields([{ 23 | name: "Number", 24 | value: this.number!.number, 25 | inline: true, 26 | }, { 27 | name: "Description", 28 | value: `\`${description}\``, 29 | inline: true, 30 | }]); 31 | 32 | await this.interaction.message!.edit({ 33 | embeds: [embed], 34 | components: [], 35 | }); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/interactions/call/411-manage-delete-cancel.ts: -------------------------------------------------------------------------------- 1 | import { ButtonInteraction, EmbedBuilder } from "discord.js"; 2 | import ComponentProcessor from "../../internals/componentProcessor"; 3 | 4 | export default class Call411EditDeleteConfirm extends ComponentProcessor { 5 | async run(): Promise { 6 | if (this.interaction.message.interaction?.user.id != this.interaction.user.id) { 7 | this.interaction.reply({ 8 | ephemeral: true, 9 | content: "❌ You can't use this menu as you didn't open it.", 10 | }); 11 | 12 | return; 13 | } 14 | 15 | const embed = new EmbedBuilder() 16 | .setColor(this.config.colors.error) 17 | .setTitle("❌ Cancelled!"); 18 | 19 | await this.interaction.message!.edit({ 20 | embeds: [embed], 21 | components: [], 22 | }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/interactions/call/411-manage-delete-confirm.ts: -------------------------------------------------------------------------------- 1 | import { ButtonInteraction, EmbedBuilder } from "discord.js"; 2 | import ComponentProcessor from "../../internals/componentProcessor"; 3 | 4 | export default class Call411EditDeleteConfirm extends ComponentProcessor { 5 | async run(): Promise { 6 | if (this.interaction.message.interaction?.user.id != this.interaction.user.id) { 7 | this.interaction.reply({ 8 | ephemeral: true, 9 | content: "❌ You can't use this menu as you didn't open it.", 10 | }); 11 | 12 | return; 13 | } 14 | 15 | this.interaction.deferUpdate(); 16 | 17 | await this.db.phonebook.delete({ 18 | where: { 19 | number: this.number!.number, 20 | }, 21 | }); 22 | 23 | const embed = new EmbedBuilder() 24 | .setColor(this.config.colors.success) 25 | .setTitle("✅ Success!") 26 | .setDescription(`Removed your number from the Yellowbook.`); 27 | 28 | await this.interaction.message!.edit({ 29 | embeds: [embed], 30 | components: [], 31 | }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/interactions/call/411-manage-edit-modal.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder } from "discord.js"; 2 | import ModalProcessor from "../../internals/modalProcessor"; 3 | 4 | 5 | export default class Call411EditAddModal extends ModalProcessor { 6 | async run(): Promise { 7 | this.interaction.deferUpdate(); 8 | 9 | const description = this.interaction.fields.getTextInputValue("description"); 10 | 11 | await this.db.phonebook.update({ 12 | where: { 13 | number: this.number!.number, 14 | }, 15 | data: { 16 | description: description, 17 | }, 18 | }); 19 | 20 | const embed = new EmbedBuilder() 21 | .setColor(this.config.colors.success) 22 | .setDescription(`Edited your Yellowbook entry.`) 23 | .setTitle("✅ Success!") 24 | .setFields([{ 25 | name: "Number", 26 | value: this.number!.number, 27 | inline: true, 28 | }, { 29 | name: "Description", 30 | value: `\`${description}\``, 31 | inline: true, 32 | }]); 33 | 34 | await this.interaction.message!.edit({ 35 | embeds: [embed], 36 | components: [], 37 | }); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/interactions/call/411-manage-selector.ts: -------------------------------------------------------------------------------- 1 | import { SelectMenuInteraction } from "discord.js"; 2 | import MessageComponentProcessor from "../../internals/componentProcessor"; 3 | import { FourOneOneEdit } from "./411-selector"; 4 | import { fourOneOneMainMenu } from "../../commands/standard/call"; 5 | 6 | export default class Call411EditSelectorSelect extends MessageComponentProcessor { 7 | async run(): Promise { 8 | const selected = this.interaction.values[0]; 9 | 10 | switch (selected) { 11 | case "add": { 12 | FourOneOneEdit.handleAddInteraction(this.interaction); 13 | break; 14 | } 15 | case "edit": { 16 | FourOneOneEdit.handleEditInteraction(this.interaction); 17 | break; 18 | } 19 | case "delete": { 20 | FourOneOneEdit.handleDeleteInteraction(this.interaction); 21 | break; 22 | } 23 | case "back": { 24 | this.interaction.deferUpdate(); 25 | this.interaction.message!.edit(fourOneOneMainMenu); 26 | break; 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/interactions/call/411-search-exit.ts: -------------------------------------------------------------------------------- 1 | import { ButtonInteraction } from "discord.js"; 2 | import ComponentProcessor from "../../internals/componentProcessor"; 3 | import { FourOneOneSearch } from "./411-selector"; 4 | 5 | export default class Call411SearchNext extends ComponentProcessor { 6 | async run(): Promise { 7 | FourOneOneSearch.exit(this.interaction); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/interactions/call/411-search-modal-submit.ts: -------------------------------------------------------------------------------- 1 | import ModalProcessor from "../../internals/modalProcessor"; 2 | import { FourOneOneSearch } from "./411-selector"; 3 | 4 | export default class Call411SearchModalSubmit extends ModalProcessor { 5 | async run(): Promise { 6 | FourOneOneSearch.handleSearchInteraction(this.interaction); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/interactions/call/411-search-next.ts: -------------------------------------------------------------------------------- 1 | import { ButtonInteraction } from "discord.js"; 2 | import ComponentProcessor from "../../internals/componentProcessor"; 3 | import { FourOneOneSearch } from "./411-selector"; 4 | 5 | export default class Call411SearchNext extends ComponentProcessor { 6 | async run(): Promise { 7 | FourOneOneSearch.page(this.interaction as ButtonInteraction, Number(this.commandData.params![0]), this.commandData.params![1], true); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/interactions/call/411-search-prev.ts: -------------------------------------------------------------------------------- 1 | import { ButtonInteraction } from "discord.js"; 2 | import ComponentProcessor from "../../internals/componentProcessor"; 3 | import { FourOneOneSearch } from "./411-selector"; 4 | 5 | export default class Call411SearchNext extends ComponentProcessor { 6 | async run(): Promise { 7 | FourOneOneSearch.page(this.interaction as ButtonInteraction, Number(this.commandData.params![0]), this.commandData.params![1], false); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/interactions/call/411-search-search.ts: -------------------------------------------------------------------------------- 1 | import { ActionRowBuilder, ButtonInteraction, ModalBuilder, TextInputBuilder, TextInputStyle } from "discord.js"; 2 | import ComponentProcessor from "../../internals/componentProcessor"; 3 | 4 | export default class Call411SearchNext extends ComponentProcessor { 5 | async run(): Promise { 6 | const modal = new ModalBuilder() 7 | .setCustomId("call-411-search-modal-submit") 8 | .setTitle("🔎 Yellowbook Search"); 9 | 10 | modal.addComponents( 11 | new ActionRowBuilder().addComponents( 12 | new TextInputBuilder() 13 | .setLabel("Search") 14 | .setPlaceholder("Search for something in the Yellowbook") 15 | .setMinLength(3) 16 | .setMaxLength(100) 17 | .setStyle(TextInputStyle.Short) 18 | .setRequired(true) 19 | .setCustomId("search-query"), 20 | ), 21 | ); 22 | 23 | this.interaction.showModal(modal); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/interactions/call/411-vip-customname-modal-submit.ts: -------------------------------------------------------------------------------- 1 | import FourOneOneVIP from "../../internals/411/vip"; 2 | import ModalProcessor from "../../internals/modalProcessor"; 3 | 4 | export default class Call411VIPCustomNameModal extends ModalProcessor { 5 | async run(): Promise { 6 | FourOneOneVIP.customNameModalSubmit(this.interaction); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/interactions/call/411-vip-hide-selector.ts: -------------------------------------------------------------------------------- 1 | import { StringSelectMenuInteraction } from "discord.js"; 2 | import FourOneOneVIP from "../../internals/411/vip"; 3 | import ComponentProcessor from "../../internals/componentProcessor"; 4 | 5 | export default class Call411VIPHideSelector extends ComponentProcessor { 6 | async run(): Promise { 7 | FourOneOneVIP.hideCallerIDSelector(this.interaction); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/interactions/call/411-vip-selector.ts: -------------------------------------------------------------------------------- 1 | import { StringSelectMenuInteraction } from "discord.js"; 2 | import ComponentProcessor from "../../internals/componentProcessor"; 3 | import FourOneOneVIP from "../../internals/411/vip"; 4 | 5 | export default class Call411VIPSelector extends ComponentProcessor { 6 | async run(): Promise { 7 | FourOneOneVIP.handleSelectorSelectionInteraction(this.interaction); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/interactions/call/411-vip-upgrade-length.ts: -------------------------------------------------------------------------------- 1 | import { StringSelectMenuInteraction } from "discord.js"; 2 | import ComponentProcessor from "../../internals/componentProcessor"; 3 | import FourOneOneVIP from "../../internals/411/vip"; 4 | 5 | export default class Call411VIPSelector extends ComponentProcessor { 6 | async run(): Promise { 7 | FourOneOneVIP.handleUpgradeLengthSelectionInteraction(this.interaction); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/interactions/call/hangup.ts: -------------------------------------------------------------------------------- 1 | import { ButtonInteraction } from "discord.js"; 2 | import MessageComponentProcessor from "../../internals/componentProcessor"; 3 | 4 | export default class CallHangupButton extends MessageComponentProcessor { 5 | async run(): Promise { 6 | const callClient = this.client.calls.find(c => c.to.channelID === this.interaction.channelId); 7 | if (!callClient) { 8 | this.interaction.reply({ 9 | embeds: [this.client.errorEmbed(this.t("errors.unexpected"))], 10 | }); 11 | return; 12 | } 13 | 14 | callClient.hangup(this.interaction); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/interactions/call/pickup.ts: -------------------------------------------------------------------------------- 1 | import { ButtonInteraction } from "discord.js"; 2 | import MessageComponentProcessor from "../../internals/componentProcessor"; 3 | 4 | export default class CallPickupButton extends MessageComponentProcessor { 5 | async run(): Promise { 6 | const callClient = Array.from(this.client.calls.values()).find(c => c.to.channelID === this.interaction.channelId); 7 | 8 | if (!callClient) { 9 | this.interaction.reply({ 10 | embeds: [this.client.errorEmbed(this.t("errors.unexpected"))], 11 | }); 12 | return; 13 | } 14 | 15 | callClient.pickup(this.interaction); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/interactions/mailbox/clear/confirm.ts: -------------------------------------------------------------------------------- 1 | import { ButtonInteraction } from "discord.js"; 2 | import ComponentProcessor from "../../../internals/componentProcessor"; 3 | 4 | export default class MailboxClearConfirm extends ComponentProcessor { 5 | async run(): Promise { 6 | await this.db.mailbox.update({ 7 | where: { 8 | number: this.number!.number, 9 | }, 10 | data: { 11 | messages: [], 12 | }, 13 | }); 14 | 15 | this.interaction.reply({ 16 | embeds: [{ 17 | color: this.config.colors.success, 18 | title: "📪 Cleared!", 19 | description: "Your mailbox has been cleared.", 20 | }], 21 | }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/interactions/mailbox/delete/select.ts: -------------------------------------------------------------------------------- 1 | import { ActionRowBuilder, StringSelectMenuBuilder, SelectMenuComponent, SelectMenuInteraction } from "discord.js"; 2 | import ComponentProcessor from "../../../internals/componentProcessor"; 3 | 4 | export default class MailboxDeleteSelect extends ComponentProcessor { 5 | async run(): Promise { 6 | const origSelectMenu = this.interaction.component as SelectMenuComponent; 7 | 8 | // Get this number's mailbox 9 | const mailbox = await this.fetchMailbox(); 10 | 11 | // See if the message actually exists 12 | const message = mailbox.messages.find(m => m.id === this.interaction.values[0]); 13 | if (!message) { 14 | this.interaction.reply({ 15 | embeds: [this.client.errorEmbed("That message doesn't exist!")], 16 | ephemeral: true, 17 | }); 18 | return; 19 | } 20 | 21 | const selectedMessageID = this.interaction.values[0]; 22 | // "Disable" the select menu 23 | const selectedMessageContent = origSelectMenu.options.find(o => o.value === selectedMessageID)!.label; 24 | 25 | // Build a new menu with no components and some placeholder text 26 | const selector = new StringSelectMenuBuilder(origSelectMenu.data) 27 | .setPlaceholder(selectedMessageContent) 28 | .setDisabled(true); 29 | 30 | this.interaction.message?.edit({ 31 | components: [new ActionRowBuilder().addComponents([selector])], 32 | }); 33 | 34 | const allMessages = mailbox.messages; 35 | allMessages.splice(allMessages.findIndex(m => m.id === selectedMessageID), 1); 36 | 37 | await this.db.mailbox.update({ 38 | where: { 39 | number: mailbox.number, 40 | }, 41 | data: { 42 | messages: allMessages, 43 | }, 44 | }); 45 | 46 | this.interaction.reply({ 47 | embeds: [{ 48 | color: 0x00FF00, 49 | title: "📭 Message Deleted", 50 | description: `Message ID **${selectedMessageID}** has been deleted.`, 51 | }], 52 | ephemeral: true, 53 | }); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/interactions/mailbox/messages/next.ts: -------------------------------------------------------------------------------- 1 | import ComponentProcessor from "../../../internals/componentProcessor"; 2 | import MailboxMessages from "../../../commands/standard/mailbox messages"; 3 | import { ButtonInteraction } from "discord.js"; 4 | 5 | export default class MailboxMessagesNext extends ComponentProcessor { 6 | async run(): Promise { 7 | MailboxMessages.displayMessages(this.interaction, this.number!.number, Number(this.interaction.customId.split("-")[3])); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/interactions/mailbox/messages/prev.ts: -------------------------------------------------------------------------------- 1 | import ComponentProcessor from "../../../internals/componentProcessor"; 2 | import MailboxMessages from "../../../commands/standard/mailbox messages"; 3 | import { ButtonInteraction } from "discord.js"; 4 | 5 | export default class MailboxMessagesPrev extends ComponentProcessor { 6 | async run(): Promise { 7 | MailboxMessages.displayMessages(this.interaction, this.number!.number, Number(this.interaction.customId.split("-")[3])); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/interactions/mailbox/send/initiate.ts: -------------------------------------------------------------------------------- 1 | import { ModalBuilder, TextInputBuilder } from "@discordjs/builders"; 2 | import { ActionRowBuilder, ButtonInteraction, TextInputStyle } from "discord.js"; 3 | import ComponentProcessor from "../../../internals/componentProcessor"; 4 | 5 | export default class MailboxSendInitiate extends ComponentProcessor { 6 | async run(): Promise { 7 | const toSendNum = this.interaction.customId.replace("mailbox-send-initiate-", ""); 8 | 9 | const modal = new ModalBuilder() 10 | .setTitle("Send a message") 11 | .setCustomId(`mailbox-send-modal-${toSendNum}`) 12 | .addComponents([ 13 | new ActionRowBuilder().addComponents([ 14 | new TextInputBuilder() 15 | .setCustomId("message") 16 | .setLabel("Message") 17 | .setPlaceholder("Enter your message here") 18 | .setStyle(TextInputStyle.Short) 19 | .setRequired(true), 20 | ]), 21 | ]); 22 | 23 | this.interaction.showModal(modal); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/interactions/mailbox/send/modal.ts: -------------------------------------------------------------------------------- 1 | import ModalProcessor from "../../../internals/modalProcessor"; 2 | import { randomString } from "../../../internals/utils"; 3 | 4 | export default class MailboxSendModalResponse extends ModalProcessor { 5 | async run(): Promise { 6 | const toSendNum = this.interaction.customId.replace("mailbox-send-modal-", ""); 7 | 8 | const toSendMailbox = await this.db.mailbox.findUnique({ 9 | where: { 10 | number: toSendNum, 11 | }, 12 | include: { 13 | numberDoc: true, 14 | }, 15 | }); 16 | 17 | toSendMailbox!.messages.push({ 18 | id: randomString(6), 19 | from: this.number!.number, 20 | message: this.interaction.fields.getTextInputValue("message"), 21 | sent: { 22 | at: new Date(), 23 | by: this.interaction.user.id, 24 | }, 25 | }); 26 | 27 | await this.db.mailbox.update({ 28 | where: { 29 | number: toSendMailbox!.number, 30 | }, 31 | data: { 32 | messages: toSendMailbox!.messages, 33 | }, 34 | }); 35 | 36 | this.interaction.reply("📫 Sent!"); 37 | 38 | this.client.sendCrossShard({ 39 | embeds: [{ 40 | color: this.config.colors.info, 41 | title: "📫 New message!", 42 | description: "You've received a new message!", 43 | fields: [{ 44 | name: "From", 45 | value: this.number!.number, 46 | inline: true, 47 | }, { 48 | name: "Message", 49 | value: this.interaction.fields.getTextInputValue("message"), 50 | inline: true, 51 | }], 52 | }], 53 | }, toSendMailbox!.numberDoc!.channelID); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/interactions/mailbox/settings/update.ts: -------------------------------------------------------------------------------- 1 | import ModalProcessor from "../../../internals/modalProcessor"; 2 | 3 | export default class MailboxSettingsUpdate extends ModalProcessor { 4 | async run(): Promise { 5 | const newAutoreplyMsg = this.interaction.fields.getTextInputValue("autoreply"); 6 | const _active = this.interaction.fields.getTextInputValue("active").toUpperCase(); 7 | 8 | // I hate this 9 | let newReceivingMessages; 10 | switch (_active) { 11 | case "ON": { 12 | newReceivingMessages = true; 13 | break; 14 | } 15 | case "OFF": { 16 | newReceivingMessages = false; 17 | break; 18 | } 19 | default: { 20 | this.interaction.reply({ 21 | embeds: [this.client.errorEmbed("Invalid value for receiving messages. Please enter either `ON` or `OFF`.")], 22 | }); 23 | return; 24 | } 25 | } 26 | 27 | await this.db.mailbox.update({ 28 | where: { 29 | number: this.number!.number, 30 | }, 31 | data: { 32 | autoreply: newAutoreplyMsg, 33 | receiving: newReceivingMessages, 34 | }, 35 | }); 36 | 37 | // TODO: Localize 38 | this.interaction.reply({ 39 | embeds: [{ 40 | color: this.config.colors.info, 41 | title: "📬 Success!", 42 | description: `Mailbox settings for \`${this.number!.number}\` updated!`, 43 | fields: [{ 44 | name: "Answering Machine", 45 | value: newAutoreplyMsg, 46 | }, { 47 | name: "Message Receiving", 48 | value: `\`${_active}\``, 49 | }], 50 | }], 51 | }); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/interactions/mention/remove/selector.ts: -------------------------------------------------------------------------------- 1 | import { ActionRowBuilder, APIEmbed, StringSelectMenuBuilder, SelectMenuComponent, SelectMenuInteraction } from "discord.js"; 2 | import ComponentProcessor from "../../../internals/componentProcessor"; 3 | 4 | export default class MentionRemoveSelector extends ComponentProcessor { 5 | async run(): Promise { 6 | const origSelectMenu = this.interaction.component as SelectMenuComponent; 7 | 8 | const selectedID = this.interaction.values[0]; 9 | const selectedUserTag = origSelectMenu.options.find(o => o.value === selectedID)!.label; 10 | 11 | const selector = new StringSelectMenuBuilder(origSelectMenu.data) 12 | .setPlaceholder(selectedUserTag) 13 | .setDisabled(true); 14 | 15 | this.interaction.message?.edit({ 16 | components: [new ActionRowBuilder().addComponents([selector])], 17 | }); 18 | 19 | const mentionsList = this.number!.mentions; 20 | mentionsList.splice(mentionsList.indexOf(selectedID), 1); 21 | 22 | await this.db.numbers.update({ 23 | where: { 24 | channelID: this.interaction.channelId, 25 | }, 26 | data: { 27 | mentions: mentionsList, 28 | }, 29 | }); 30 | 31 | this.interaction.reply({ 32 | embeds: [{ 33 | color: 0x00FF00, 34 | ...this.t("removeEmbed", { 35 | user: selectedUserTag, 36 | }) as APIEmbed, 37 | }], 38 | }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/interactions/wizard/modalSubmit.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { APIEmbed } from "discord.js"; 3 | import ModalProcessor from "../../internals/modalProcessor"; 4 | import { parseNumber } from "../../internals/utils"; 5 | 6 | export default class WizardModalSubmit extends ModalProcessor { 7 | async run(): Promise { 8 | const number = parseNumber(this.interaction.fields.getTextInputValue("wizardNumber")); 9 | 10 | if (isNaN(Number(number))) { 11 | this.interaction.reply({ content: `${this.t("errors.numberInvalid")}`, ephemeral: true }); 12 | return; 13 | } 14 | if (!number.startsWith(this.numberShouldStartWith())) { 15 | this.interaction.reply({ content: `${this.t("errors.numberBadFormat", { numberStartsWith: this.numberShouldStartWith() })}`, ephemeral: true }); 16 | return; 17 | } 18 | 19 | const dbNumber = await this.db.numbers.findUnique({ 20 | where: { 21 | number: number, 22 | }, 23 | }); 24 | 25 | if (dbNumber) { 26 | this.interaction.reply({ content: `${this.t("errors.numberInUse")}`, ephemeral: true }); 27 | return; 28 | } 29 | 30 | const expiry = dayjs().add(1, "month").toDate(); 31 | 32 | await this.db.numbers.create({ 33 | data: { 34 | number: number, 35 | channelID: this.interaction.channelId!, 36 | guildID: this.interaction.guildId!, 37 | expiry, 38 | }, 39 | }); 40 | 41 | 42 | this.interaction.reply({ 43 | embeds: [{ 44 | color: this.config.colors.success, 45 | 46 | ...this.t("successEmbed", { returnObjects: true, number: this.interaction.fields.getTextInputValue("wizardNumber").toUpperCase(), expiry }) as APIEmbed, 47 | }], 48 | }); 49 | 50 | this.client.log(`📘 Number \`${number}\` has been self-assigned to \`${this.interaction.channelId}\` by \`${this.interaction.user.username}\` \`${this.interaction.user.id}\``); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/interactions/wizard/ready.ts: -------------------------------------------------------------------------------- 1 | import ComponentProcessor from "../../internals/componentProcessor"; 2 | import { TextInputBuilder, ModalBuilder, ActionRowBuilder, ButtonInteraction, TextInputStyle } from "discord.js"; 3 | 4 | export default class WizardReadyButton extends ComponentProcessor { 5 | async _run(): Promise { 6 | super._run(); 7 | 8 | this.client.editCrossShard({ 9 | embeds: this.interaction.message.embeds, 10 | components: [], 11 | }, this.interaction.channelId, this.interaction.message.id); 12 | } 13 | 14 | async run(): Promise { 15 | const modal = new ModalBuilder() 16 | .setTitle(this.t("modal.title")) 17 | .setCustomId("wizard-modalSubmit"); 18 | 19 | const numberInputComponent = new TextInputBuilder() 20 | .setCustomId("wizardNumber") 21 | .setLabel(this.t("modal.numberLabel")) 22 | .setRequired(true) 23 | .setMaxLength(11) 24 | .setMinLength(11) 25 | .setPlaceholder(`${this.numberShouldStartWith()}xxxxxxx`) 26 | .setStyle(TextInputStyle.Short); 27 | 28 | const row = new ActionRowBuilder(); 29 | row.addComponents(numberInputComponent); 30 | 31 | modal.addComponents(row); 32 | 33 | this.interaction.showModal(modal); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/interfaces/commandData.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | import { ApplicationCommandOptionData, ApplicationCommandSubCommandData, ChatInputApplicationCommandData } from "discord.js"; 3 | 4 | // eslint-disable-next-line no-shadow 5 | enum PermissionLevel { 6 | none, 7 | donator, 8 | contributor, 9 | serverAdmin, 10 | customerSupport, 11 | manager, 12 | maintainer, 13 | } 14 | 15 | // eslint-disable-next-line no-shadow 16 | enum CommandType { 17 | standard, 18 | call, 19 | customerSupport, 20 | maintainer, 21 | } 22 | 23 | interface SubcommandData extends ApplicationCommandSubCommandData { 24 | permissionLevel: PermissionLevel; 25 | useType: CommandType; 26 | } 27 | 28 | type CommandOptions = ApplicationCommandOptionData | SubcommandData; 29 | 30 | interface CommandData extends ChatInputApplicationCommandData { 31 | options?: CommandOptions[], 32 | 33 | guildOnly?: boolean; 34 | numberRequired?: boolean; 35 | accountRequired?: boolean; 36 | 37 | permissionLevel: PermissionLevel; 38 | useType: CommandType; 39 | 40 | notExecutableInCall?: boolean; 41 | 42 | params?: string[]; 43 | } 44 | export default CommandData; 45 | export { PermissionLevel, CommandType, SubcommandData }; 46 | 47 | -------------------------------------------------------------------------------- /src/interfaces/constructable.ts: -------------------------------------------------------------------------------- 1 | export default interface Constructable { 2 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, no-unused-vars 3 | new(...args: any) : T; 4 | } 5 | -------------------------------------------------------------------------------- /src/interfaces/numbersWithGuilds.ts: -------------------------------------------------------------------------------- 1 | import { GuildConfigs, Numbers } from "@prisma/client"; 2 | 3 | export type NumbersWithGuilds = Numbers & { 4 | guild?: GuildConfigs | null, 5 | }; 6 | -------------------------------------------------------------------------------- /src/internals/411/vip.ts: -------------------------------------------------------------------------------- 1 | import { Numbers } from "@prisma/client"; 2 | import { ActionRowBuilder, BaseMessageOptions, EmbedBuilder, ModalBuilder, ModalSubmitInteraction, StringSelectMenuBuilder, StringSelectMenuInteraction, StringSelectMenuOptionBuilder, TextInputBuilder, TextInputStyle } from "discord.js"; 3 | import { db } from "../../database/db"; 4 | import { client } from "../../dtel"; 5 | import config from "../../config/config"; 6 | import { fetchNumber, getOrCreateAccount } from "../utils"; 7 | import { getFixedT } from "i18next"; 8 | import dayjs from "dayjs"; 9 | import { fourOneOneMainMenu } from "../../commands/standard/call"; 10 | 11 | class FourOneOneVIP { 12 | static makeMenuEmbed(number: Numbers, settingsChanged = false): BaseMessageOptions { 13 | const actionRow = new ActionRowBuilder(); 14 | const selectMenu = new StringSelectMenuBuilder() 15 | .setPlaceholder("Options") 16 | .setCustomId("call-411-vip-selector"); 17 | actionRow.addComponents(selectMenu); 18 | 19 | if (!number.vip || number.vip?.expiry < new Date()) { 20 | selectMenu.addOptions( 21 | new StringSelectMenuOptionBuilder() 22 | .setEmoji({ name: `⬆️` }) 23 | .setLabel("Upgrade") 24 | .setDescription("Make your number a VIP number") 25 | .setValue("upgrade"), 26 | ); 27 | } else { 28 | selectMenu.addOptions([ 29 | new StringSelectMenuOptionBuilder() 30 | .setEmoji({ name: `🤫` }) 31 | .setLabel("Hide") 32 | .setDescription("Prevent your number from showing when you call others") 33 | .setValue("hide"), 34 | new StringSelectMenuOptionBuilder() 35 | .setEmoji({ name: `📛` }) 36 | .setLabel("Custom Name") 37 | .setDescription("Show a custom name when you call someone") 38 | .setValue("custom"), 39 | ]); 40 | } 41 | 42 | selectMenu.addOptions( 43 | new StringSelectMenuOptionBuilder() 44 | .setEmoji({ name: `⬅️` }) 45 | .setLabel("Back") 46 | .setDescription("Go back to the Main Menu.") 47 | .setValue("back"), 48 | ); 49 | 50 | const embed = new EmbedBuilder() 51 | .setColor(config.colors.yellowbook); 52 | 53 | if (settingsChanged) { 54 | embed 55 | .setTitle("✅ VIP Settings") 56 | .setDescription("Your settings have been updated.") 57 | .setFooter({ 58 | text: "Please select an option from the dropdown menu below.", 59 | }); 60 | } else { 61 | embed 62 | .setTitle("VIP Settings") 63 | .setDescription("Please select an option from the dropdown menu below."); 64 | } 65 | 66 | if (number.vip) { 67 | embed.addFields({ 68 | name: "Display Name", 69 | value: number.vip!.name || "None", 70 | inline: true, 71 | }, { 72 | name: "Caller ID Hidden?", 73 | value: number.vip!.hidden ? "✅ Yes" : "❌ No", 74 | inline: true, 75 | }, { 76 | name: "Expiration Date", 77 | value: dayjs(number.vip!.expiry).format("YYYY-MM-DD"), 78 | }); 79 | } 80 | 81 | return { 82 | components: [actionRow], 83 | embeds: [embed], 84 | }; 85 | } 86 | 87 | static async mainMenu(interaction: StringSelectMenuInteraction) { 88 | interaction.deferUpdate(); 89 | 90 | let number: Numbers; 91 | try { 92 | number = await db.numbers.findUniqueOrThrow({ 93 | where: { 94 | channelID: interaction.channelId, 95 | }, 96 | }); 97 | } catch { 98 | interaction.message.edit({ 99 | embeds: [client.errorEmbed("An unexpected error occurred. Please try again")], 100 | components: [], 101 | }); 102 | return; 103 | } 104 | 105 | interaction.message!.edit(this.makeMenuEmbed(number)); 106 | } 107 | 108 | static async handleSelectorSelectionInteraction(interaction: StringSelectMenuInteraction) { 109 | if (interaction.message.interaction?.user.id != interaction.user.id) { 110 | interaction.reply({ 111 | ephemeral: true, 112 | content: "❌ You can't use this menu as you didn't open it.", 113 | }); 114 | 115 | return; 116 | } 117 | 118 | switch (interaction.values[0]) { 119 | case "upgrade": { 120 | interaction.deferUpdate(); 121 | return this.handleSelectorUpgradeSelectionInteraction(interaction); 122 | } 123 | case "hide": { 124 | return this.handleSelectorHideSelectionInteraction(interaction); 125 | } 126 | case "custom": { 127 | return this.handleSelectorCustomNameSelectionInteraction(interaction); 128 | } 129 | case "back": { 130 | interaction.deferUpdate(); 131 | interaction.message.edit(fourOneOneMainMenu); 132 | } 133 | } 134 | } 135 | 136 | static async handleSelectorUpgradeSelectionInteraction(interaction: StringSelectMenuInteraction) { 137 | const genericT = getFixedT(interaction.locale, undefined, "generic"); 138 | 139 | const account = await getOrCreateAccount(interaction.user.id); 140 | const number = (await fetchNumber(interaction.channelId))!; 141 | 142 | const canAffordUpgrade = account!.vipMonthsRemaining > 0; 143 | const numberIsVIP = number.vip && number.vip?.expiry > new Date(); 144 | 145 | if (canAffordUpgrade && !numberIsVIP) { 146 | const monthSelectorOptions: StringSelectMenuOptionBuilder[] = []; 147 | // For up to 12 months 148 | for (let i = 1; i <= 12; i++) { 149 | if (i > account.vipMonthsRemaining) break; 150 | 151 | const cost = config.renewalRate * i; 152 | if (cost > account.balance) break; 153 | 154 | const option = new StringSelectMenuOptionBuilder() 155 | .setLabel(genericT("month", { 156 | count: i, 157 | lng: interaction.locale, 158 | })) 159 | .setValue(`${i}`); 160 | 161 | monthSelectorOptions.push(option); 162 | } 163 | 164 | const embed = new EmbedBuilder() 165 | .setColor(config.colors.yellowbook) 166 | .setTitle("VIP Upgrade") 167 | .setDescription("Please select the length of your VIP experience from the dropdown menu below."); 168 | 169 | interaction.message!.edit({ 170 | components: [new ActionRowBuilder().addComponents([ 171 | new StringSelectMenuBuilder() 172 | .setCustomId("call-411-vip-upgrade-length") 173 | .setPlaceholder("VIP Length") 174 | .addOptions(monthSelectorOptions), 175 | ])], 176 | embeds: [embed], 177 | }); 178 | } 179 | } 180 | 181 | static async hideCallerIDSelector(interaction: StringSelectMenuInteraction) { 182 | const value = interaction.values[0]; 183 | 184 | await interaction.deferUpdate(); 185 | 186 | const hidden = value === "hide"; 187 | 188 | const number = await db.numbers.update({ 189 | where: { 190 | channelID: interaction.channelId!, 191 | }, 192 | data: { 193 | vip: { 194 | upsert: { 195 | set: { 196 | hidden, 197 | name: "", 198 | expiry: new Date(0), 199 | }, 200 | update: { 201 | hidden, 202 | }, 203 | }, 204 | }, 205 | }, 206 | }); 207 | 208 | interaction.message!.edit(this.makeMenuEmbed(number, true)); 209 | } 210 | 211 | static async handleUpgradeLengthSelectionInteraction(interaction: StringSelectMenuInteraction) { 212 | if (interaction.message.interaction?.user.id != interaction.user.id) { 213 | interaction.reply({ 214 | ephemeral: true, 215 | content: "❌ You can't use this menu as you didn't open it.", 216 | }); 217 | 218 | return; 219 | } 220 | 221 | interaction.deferUpdate(); 222 | 223 | let account = await getOrCreateAccount(interaction.user.id); 224 | let number = (await fetchNumber(interaction.channelId))!; 225 | 226 | const selected = Number(interaction.values[0]); 227 | 228 | if (selected > account.vipMonthsRemaining) { 229 | interaction.message!.edit({ 230 | components: [], 231 | embeds: [client.errorEmbed("Something went wrong. Please try again.")], 232 | }); 233 | return; 234 | } 235 | 236 | const newMonthsRemaining = account.vipMonthsRemaining - selected; 237 | 238 | account = await db.accounts.update({ 239 | where: { 240 | id: account.id, 241 | }, 242 | data: { 243 | vipMonthsRemaining: newMonthsRemaining, 244 | }, 245 | }); 246 | 247 | const dateSeed = number.vip && number.vip.expiry > new Date() ? number.vip?.expiry : new Date(); 248 | 249 | number = await db.numbers.update({ 250 | where: { 251 | number: number.number, 252 | }, 253 | data: { 254 | vip: { 255 | set: { 256 | expiry: dayjs(dateSeed).add(selected, "month").toDate(), // Copilot gave this so I'll use it 257 | }, 258 | }, 259 | }, 260 | }); 261 | 262 | const embed = new EmbedBuilder() 263 | .setColor(config.colors.yellowbook) 264 | .setAuthor({ 265 | name: interaction.user.username, 266 | iconURL: interaction.user.displayAvatarURL(), 267 | }) 268 | .setTitle("VIP Upgrade Receipt") 269 | .setDescription(`You have successfully upgraded your number to VIP for ${selected} month${selected === 1 ? "" : "s"}.`) 270 | .addFields([{ 271 | name: "Number", 272 | value: number.number, 273 | inline: true, 274 | }, { 275 | name: "Expiration Date", 276 | value: dayjs(number.expiry).format("YYYY-MM-DD"), 277 | inline: true, 278 | }, { 279 | name: "Donator Months Remaining", 280 | value: `${account.vipMonthsRemaining} month${account.vipMonthsRemaining === 1 ? "" : "s"}`, 281 | }, { 282 | name: "VIP Options", 283 | value: "Dial `*411` again to manage your VIP settings.", 284 | }]); 285 | 286 | await interaction.message.edit({ 287 | components: [], 288 | embeds: [embed], 289 | }); 290 | } 291 | 292 | static handleSelectorHideSelectionInteraction(interaction: StringSelectMenuInteraction) { 293 | const embed = new EmbedBuilder() 294 | .setColor(config.colors.yellowbook) 295 | .setTitle("VIP Anonymous Mode") 296 | .setDescription("Anonymous Mode hides your number (if you don't have a custom name set) when you call others. It will also hide your name when you send in a call.\n*Abuse of this system will not be tolerated*") 297 | .setFooter({ 298 | text: "Please select an option from the dropdown menu below.", 299 | }); 300 | 301 | const actionRow = new ActionRowBuilder(); 302 | actionRow.addComponents([ 303 | new StringSelectMenuBuilder().addOptions([ 304 | new StringSelectMenuOptionBuilder() 305 | .setEmoji("❗") 306 | .setLabel("Show") 307 | .setDescription(`Displays your name/number when you call others`) 308 | .setValue("show"), 309 | new StringSelectMenuOptionBuilder() 310 | .setEmoji("🤫") 311 | .setLabel("Hide") 312 | .setDescription(`Anonymises your messages when you call others.`) 313 | .setValue("hide"), 314 | ]).setCustomId("call-411-vip-hide-selector").setPlaceholder("Hide Caller Details?"), 315 | ]); 316 | 317 | interaction.deferUpdate(); 318 | interaction.message!.edit({ 319 | embeds: [embed], 320 | components: [actionRow], 321 | }); 322 | } 323 | 324 | static async handleSelectorCustomNameSelectionInteraction(interaction: StringSelectMenuInteraction) { 325 | const number = await fetchNumber(interaction.channelId); 326 | 327 | const textInput = new TextInputBuilder() 328 | .setCustomId("name") 329 | .setPlaceholder("Display Name") 330 | .setLabel("Display Name (Leave blank to remove)") 331 | .setStyle(TextInputStyle.Short) 332 | .setMinLength(4) 333 | .setMaxLength(25) 334 | .setRequired(false); 335 | 336 | if (number!.vip!.name.length != 0) { 337 | textInput.setValue(number!.vip!.name); 338 | } 339 | 340 | const modal = new ModalBuilder() 341 | .setTitle("VIP Custom Name") 342 | .setCustomId("call-411-vip-customname-modal-submit") 343 | .setComponents([ 344 | new ActionRowBuilder().addComponents([ 345 | textInput, 346 | ]), 347 | ]); 348 | 349 | interaction.showModal(modal); 350 | } 351 | 352 | static async customNameModalSubmit(interaction: ModalSubmitInteraction) { 353 | let customName = interaction.fields.getTextInputValue("name"); 354 | 355 | if (customName == "") customName = ""; 356 | 357 | const numberDoc = await db.numbers.update({ 358 | where: { 359 | channelID: interaction.channelId!, 360 | }, 361 | data: { 362 | vip: { 363 | upsert: { 364 | set: { 365 | name: "", 366 | hidden: false, 367 | expiry: new Date(0), 368 | }, 369 | update: { 370 | name: customName, 371 | }, 372 | }, 373 | }, 374 | }, 375 | }); 376 | 377 | interaction.deferUpdate(); 378 | 379 | interaction.message!.edit(this.makeMenuEmbed(numberDoc, true)); 380 | } 381 | } 382 | 383 | export default FourOneOneVIP; 384 | -------------------------------------------------------------------------------- /src/internals/client.ts: -------------------------------------------------------------------------------- 1 | import { Channel, Client, ClientOptions, DMChannel, EmbedBuilder, Guild, MessageCreateOptions, Role, ShardClientUtil, Snowflake, TextChannel, User } from "discord.js"; 2 | import config from "../config/config"; 3 | import CallClient from "./callClient"; 4 | import { Collection } from "@discordjs/collection"; 5 | import { APIEmbed, APIMessage, APITextChannel, ChannelType, RESTPatchAPIChannelMessageResult, RESTPostAPIChannelMessageResult } from "discord-api-types/v10"; 6 | import { PermissionLevel } from "../interfaces/commandData"; 7 | import { winston } from "../dtel"; 8 | import { Logger } from "winston"; 9 | import { db } from "../database/db"; 10 | import { Numbers } from "@prisma/client"; 11 | import { fetchNumber, parseNumber } from "./utils"; 12 | import dayjs from "dayjs"; 13 | 14 | interface PossibleTypes { 15 | user?: User | null, 16 | guild?: Guild, 17 | number?: Numbers | null, 18 | } 19 | 20 | class DTelClient extends Client { 21 | config = config; 22 | 23 | db = db; 24 | winston: Logger = winston; 25 | 26 | calls = new Collection(); 27 | 28 | shardWithSupportGuild = 0; 29 | 30 | permsCache: Collection = new Collection(); 31 | 32 | allShardsSpawned = false; 33 | 34 | constructor(options: ClientOptions) { 35 | super(options); 36 | this.shardWithSupportGuild = ShardClientUtil.shardIdForGuildId(config.supportGuild.id, config.shardCount); 37 | } 38 | 39 | errorEmbed(description: string, options?: APIEmbed): APIEmbed { 40 | return { 41 | color: config.colors.error, 42 | title: "❌ Error!", 43 | description, 44 | ...options, 45 | }; 46 | } 47 | 48 | warningEmbed(description: string, options?: APIEmbed): APIEmbed { 49 | return { 50 | color: 0xFFFF00, 51 | title: "⚠️ Warning!", 52 | description, 53 | ...options, 54 | }; 55 | } 56 | 57 | async sendCrossShard(options: MessageCreateOptions | string, channelID: Snowflake | string): Promise { 58 | const body = options instanceof Object ? options : { 59 | content: options, 60 | }; 61 | 62 | return this.rest.post(`/channels/${channelID}/messages`, { 63 | body, 64 | }) as Promise; 65 | } 66 | 67 | async editCrossShard(options: MessageCreateOptions, channelID: string, messageID: string): Promise { 68 | return this.rest.patch(`/channels/${channelID}/messages/${messageID}`, { 69 | body: options, 70 | }) as Promise; 71 | } 72 | 73 | async deleteCrossShard(channelID: string, messageID: string): Promise { 74 | return this.rest.delete(`/channels/${channelID}/messages/${messageID}`) as Promise; 75 | } 76 | 77 | async shardIdForChannelId(id: string): Promise { 78 | if (!process.env.SHARD_COUNT || Number(process.env.SHARD_COUNT) == 1) return 0; 79 | const channelObject = await this.rest.get(`/channels/${id}`) as APITextChannel; 80 | 81 | if (channelObject && !channelObject.guild_id) return 0; 82 | 83 | return ShardClientUtil.shardIdForGuildId(channelObject.guild_id as string, Number(process.env.SHARD_COUNT)); 84 | } 85 | 86 | // Use these so that we can edit them if we get performance issues 87 | async getUser(id: string): Promise { 88 | return this.users.fetch(id); 89 | } 90 | async getGuild(id: string): Promise { 91 | // Not safe to cache this as we won't get its updates 92 | return this.guilds.fetch({ 93 | guild: id, 94 | cache: false, 95 | }); 96 | } 97 | async getChannel(id: string): Promise { 98 | // Not safe to cache this as we won't get its updates 99 | return this.channels.fetch(id, { 100 | cache: false, 101 | }); 102 | } 103 | 104 | async getPerms(userID: string): Promise> { 105 | // We don't deal with serverAdmin here 106 | if (config.maintainers.includes(userID)) return PermissionLevel.maintainer; 107 | 108 | // Get perms from cache 109 | let perms = this.permsCache.get(userID); 110 | 111 | if (!perms) { 112 | const supportGuild = await this.guilds.fetch(config.supportGuild.id); 113 | const member = await supportGuild.members.fetch(userID).catch(() => null); 114 | 115 | const roles = member?.roles.cache; 116 | 117 | if (!roles || roles.size === 0) perms = PermissionLevel.none; 118 | else if (roles.find(r => r.id === config.supportGuild.roles.manager)) perms = PermissionLevel.manager; 119 | else if (roles.find(r => r.id === config.supportGuild.roles.customerSupport)) perms = PermissionLevel.customerSupport; 120 | else if (roles.find(r => r.id === config.supportGuild.roles.contributor)) perms = PermissionLevel.contributor; 121 | else if (roles.find(r => r.id === config.supportGuild.roles.donator)) perms = PermissionLevel.donator; 122 | else perms = PermissionLevel.none; 123 | 124 | // Rolling cache, I have a strange feeling this will cause issues in the future 125 | if (this.permsCache.size > 200) { 126 | for (const i of this.permsCache.lastKey(200 - this.permsCache.size)!) { 127 | this.permsCache.delete(i); 128 | } 129 | } 130 | // Cache user if they're not cached on this shard already 131 | this.permsCache.set(userID, perms); 132 | } 133 | 134 | return perms; 135 | } 136 | 137 | async resolveGuildChannelNumberUser(toResolve: string): Promise { 138 | toResolve = parseNumber(toResolve); 139 | const possibilities: PossibleTypes = {}; 140 | 141 | // THIS IS HELL 142 | // But I'm in a rush and it's kinda clean 143 | if (toResolve.length == 11) { 144 | possibilities.number = await fetchNumber(toResolve); 145 | 146 | if (possibilities.number) { 147 | if (possibilities.number?.guildID) { 148 | possibilities.guild = await this.getGuild(possibilities.number.guildID).catch(() => undefined); 149 | } else { 150 | const tempChan = await this.getChannel(possibilities.number.channelID).catch(() => undefined); 151 | if (tempChan?.isDMBased()) { 152 | possibilities.user = (tempChan as DMChannel).recipient; 153 | } else { 154 | possibilities.guild = (tempChan as TextChannel | null | undefined)?.guild; 155 | } 156 | } 157 | } 158 | } else { 159 | possibilities.user = await this.getUser(toResolve).catch(() => undefined); 160 | // If toResolve is a User 161 | if (!possibilities.user) { 162 | // Else try again 163 | possibilities.guild = await this.getGuild(toResolve).catch(() => undefined); 164 | } 165 | 166 | // If the ID is a guild 167 | if (!possibilities.guild) { 168 | // Else if we still don't know what the toResolve is, try chanel 169 | const channel = await this.getChannel(toResolve).catch(() => undefined); 170 | if (channel) { 171 | if (channel.type === ChannelType.DM) { 172 | possibilities.user = (channel as DMChannel).recipient; 173 | } else { 174 | possibilities.guild = (channel as TextChannel).guild; 175 | } 176 | } 177 | } 178 | } 179 | return possibilities; 180 | } 181 | 182 | async deleteNumber(number: string): Promise { 183 | const numberDoc = await this.db.numbers.findUnique({ 184 | where: { 185 | number, 186 | }, 187 | include: { 188 | incomingCalls: true, 189 | outgoingCalls: true, 190 | }, 191 | }); 192 | if (!numberDoc) return false; 193 | 194 | // Delete the phonebook entry and the mailbox first 195 | await this.db.phonebook.delete({ 196 | where: { 197 | number: number, 198 | }, 199 | }).catch(() => null); 200 | await this.db.mailbox.delete({ 201 | where: { 202 | number: number, 203 | }, 204 | }).catch(() => null); 205 | 206 | if (numberDoc.outgoingCalls.length > 0 || numberDoc.incomingCalls.length > 0) { 207 | for (const call of this.calls.filter(c => c.from.number === number || c.to.number === number)) { 208 | call[1].endHandler("system - number deleted"); 209 | 210 | this.sendCrossShard({ 211 | content: "The number you were calling has been deleted and as such this call has been terminated.", 212 | }, call[1].getOtherSide(numberDoc.channelID).channelID).catch(() => null); 213 | } 214 | } 215 | 216 | 217 | await db.numbers.delete({ 218 | where: { 219 | number: numberDoc.number, 220 | }, 221 | }); 222 | 223 | this.log(`📕 Number \`${number}\` has been automatically deassigned as its channel has been deleted.`); 224 | 225 | let ownerDMChannel: DMChannel | null | undefined; 226 | 227 | if (numberDoc.guildID) { 228 | const guild = await this.getGuild(numberDoc.guildID).catch(() => null); 229 | const owner = await guild?.fetchOwner().catch(() => null); 230 | 231 | ownerDMChannel = owner?.dmChannel; 232 | } else { 233 | const channel = await this.getChannel(numberDoc.channelID).catch(() => null) as DMChannel | null; 234 | 235 | ownerDMChannel = channel; 236 | } 237 | 238 | if (ownerDMChannel) { 239 | const ownerEmbed = new EmbedBuilder() 240 | .setColor(this.config.colors.info) 241 | .setDescription([ 242 | `One of our staff members has removed the number in ${numberDoc.guildID ? `<#${numberDoc.channelID}>` : "your DMs"}.`, 243 | "", 244 | "If this action wasn't requested, and you feel like it is unjust, you can dispute the removal in our support server (`/links`)", 245 | ].join("\n")) 246 | .setTimestamp(new Date()); 247 | 248 | ownerDMChannel.send({ 249 | embeds: [ownerEmbed], 250 | }).catch(() => null); 251 | } 252 | return true; 253 | } 254 | 255 | // Sends to the support guild's log channel 256 | async log(message: string): Promise { 257 | winston.verbose(message); 258 | 259 | const time = dayjs().format("HH:mm:ss"); 260 | return this.sendCrossShard({ 261 | content: `\`[${time}]\` ${message}`, 262 | }, this.config.supportGuild.channels.logs); 263 | } 264 | 265 | async getGuildCount(): Promise { 266 | if (!this.allShardsSpawned) return -1; 267 | 268 | return (await this.shard!.fetchClientValues("guilds.cache.size")).reduce((a, b) => (a as number) + (b as number), 0) as number; 269 | } 270 | } 271 | 272 | export default DTelClient; 273 | -------------------------------------------------------------------------------- /src/internals/commandProcessor.ts: -------------------------------------------------------------------------------- 1 | // This deviates so far from Novus FM that this may as well be classified as new code 2 | import { ChatInputCommandInteraction } from "discord.js"; 3 | import CommandDataInterface from "../interfaces/commandData"; 4 | import DTelClient from "./client"; 5 | import Processor from "./processor"; 6 | import i18n, { TFunction } from "i18next"; 7 | 8 | abstract class CommandProcessor extends Processor { 9 | commandData: CommandDataInterface; 10 | interaction: ChatInputCommandInteraction; 11 | t: TFunction; 12 | 13 | constructor(client: DTelClient, interaction: ChatInputCommandInteraction, commandData: CommandDataInterface) { 14 | super(client, interaction, commandData); 15 | this.interaction = interaction; 16 | this.commandData = commandData; 17 | 18 | this.t = i18n.getFixedT(interaction.locale, undefined, `commands.${interaction.commandName}`); 19 | } 20 | 21 | async _run(): Promise { 22 | // Maybe this should be moved into the event handler? 23 | if (this.config.devOnlyMode && this.interaction.guild && (!this.client.config.maintainers.includes(this.interaction.user.id))) { 24 | await this.permCheckFail(); 25 | return; 26 | } else if (this.commandData.guildOnly && !this.interaction.guild) { 27 | await this.guildOnly(); 28 | return; 29 | } 30 | 31 | await super._run(); 32 | } 33 | } 34 | export default CommandProcessor; 35 | -------------------------------------------------------------------------------- /src/internals/componentProcessor.ts: -------------------------------------------------------------------------------- 1 | // File needs a better name 2 | 3 | import i18n, { TFunction } from "i18next"; 4 | 5 | import DTelClient from "./client"; 6 | import Processor from "./processor"; 7 | import CommandDataInterface from "../interfaces/commandData"; 8 | import { MessageComponentInteraction } from "discord.js"; 9 | 10 | abstract class ComponentProcessor extends Processor { 11 | interaction: T; 12 | t: TFunction; 13 | 14 | constructor(client: DTelClient, interaction: T, commandData: CommandDataInterface) { 15 | super(client, interaction, commandData); 16 | this.interaction = interaction; 17 | 18 | this.t = i18n.getFixedT(interaction.locale, undefined, `commands.${interaction.customId.split("-")[0]}`); 19 | } 20 | abstract run(): void; 21 | } 22 | export default ComponentProcessor; 23 | -------------------------------------------------------------------------------- /src/internals/console.ts: -------------------------------------------------------------------------------- 1 | // Not stolen code 2 | // © SunburntRock89 2021 3 | // © theLMGN 2021 4 | // © Vlad Frangu 2019 5 | 6 | import { transports, format, createLogger, Logger } from "winston"; 7 | import DailyRotateFile from "winston-daily-rotate-file"; 8 | import chalk from "chalk"; 9 | import moment from "moment"; 10 | import path from "path"; 11 | import util from "util"; 12 | import config from "../config/config"; // oh i was just thinking the classes, dte 13 | 14 | export default (type = "Master"): Logger => createLogger({ 15 | levels: { 16 | error: 0, 17 | warn: 1, 18 | info: 2, 19 | debug: 3, 20 | verbose: 4, 21 | silly: 5, 22 | }, 23 | transports: [ 24 | new transports.Console({ 25 | level: config.winston.consoleLevel, 26 | format: format.combine( 27 | format.prettyPrint({ depth: 5 }), 28 | format.label({ label: `[DTel -- ${type}]` }), 29 | format.colorize(), 30 | format.timestamp({ format: () => `[${chalk.grey(moment().format("HH:mm:ss"))}]` }), 31 | format.printf(({ level, message, label, timestamp }) => `${timestamp} - ${level}: ${label} ${typeof message === "object" ? util.inspect(message, false, 2, true) : message}`), 32 | ), 33 | }), 34 | new DailyRotateFile({ 35 | level: config.winston.fileLevel, 36 | format: format.combine( 37 | format.prettyPrint(), 38 | format.printf(({ level, message }) => `(${moment().format("DD-MM-YYYY HH:mm:ss")}) (${level.toUpperCase()}) ${typeof message === "object" ? util.inspect(message, false, 2, false) : message}`), 39 | format.colorize(), 40 | ), 41 | datePattern: `DD-MM-yyyy`, 42 | json: false, 43 | extension: ".log", 44 | // eslint-disable-next-line no-unused-vars 45 | filename: path.join(process.cwd(), `../Logs/%DATE%-DTel-${type}`), 46 | }), 47 | ], 48 | }); 49 | 50 | -------------------------------------------------------------------------------- /src/internals/folder.md: -------------------------------------------------------------------------------- 1 | # src/internals 2 | This folder is basically just anything that doesn't fit into any other folder. 3 | Think of it as miscellaneous things that don't need to be elsewhere (for example, the console). -------------------------------------------------------------------------------- /src/internals/jobs.ts: -------------------------------------------------------------------------------- 1 | import { Range, scheduleJob } from "node-schedule"; 2 | import { client, winston } from "../dtel"; 3 | import auth from "../config/auth"; 4 | import config from "../config/config"; 5 | import { db } from "../database/db"; 6 | import { EmbedBuilder } from "discord.js"; 7 | 8 | interface playingCtx { 9 | guildCount: number; 10 | userCount: number; 11 | } 12 | 13 | const playingJob = scheduleJob( 14 | { 15 | minute: new Range(0, 59, 15), 16 | }, 17 | async () => { 18 | const guildCount = await client.getGuildCount(); 19 | 20 | const userCount = ( 21 | await client.shard!.fetchClientValues("users.cache.size") 22 | ).reduce((a, b) => (a as number) + (b as number), 0) as number; 23 | 24 | client.shard!.broadcastEval( 25 | (c, ctx) => { 26 | c.user!.setActivity({ 27 | name: `${ctx.guildCount.toString()} servers and ${ctx.userCount.toString()} users | /help`, 28 | type: 3, 29 | }); 30 | }, 31 | { 32 | context: { 33 | guildCount: guildCount, 34 | userCount: userCount, 35 | }, 36 | } 37 | ); 38 | 39 | winston.verbose("[Jobs] Updated playing status."); 40 | } 41 | ); 42 | 43 | if (!config.devMode) { 44 | scheduleJob("*/1 * * * *", async () => { 45 | const guildCount = await client.getGuildCount(); 46 | 47 | let updateResults: Response; 48 | try { 49 | updateResults = await fetch("https://discord.austinhuang.me/dtel", { 50 | method: "GET", 51 | headers: { 52 | Authorization: auth.blspace, 53 | "Content-Type": "application/json", 54 | count: guildCount.toString(), 55 | }, 56 | }); 57 | } catch (err) { 58 | winston.error( 59 | `Yo, there might be something wrong with the votes API.\n\`\`\`\n${err}\n\`\`\``, 60 | err 61 | ); 62 | return; 63 | } 64 | 65 | let responseBody; 66 | try { 67 | responseBody = await updateResults.json(); // Array of User IDs 68 | } catch (e) { 69 | await client.sendCrossShard( 70 | `Yo, there might be something wrong with the votes API.\n\`\`\`\n${e}\n\`\`\``, 71 | config.supportGuild.channels.management 72 | ); 73 | return; 74 | } 75 | 76 | const userIDs = Object.keys(responseBody); 77 | 78 | for (const id of userIDs) { 79 | const user = await client.getUser(id); 80 | if (!user) { 81 | continue; 82 | } 83 | 84 | await db.accounts.upsert({ 85 | where: { 86 | id: user.id, 87 | }, 88 | update: { 89 | balance: { 90 | increment: responseBody[id], 91 | }, 92 | }, 93 | create: { 94 | id: user.id, 95 | balance: responseBody[id], 96 | }, 97 | }); 98 | 99 | // save the votes for leaderboard 100 | 101 | await db.votes.upsert({ 102 | where: { 103 | userID: user.id, 104 | }, 105 | update: { 106 | count: { 107 | increment: responseBody[user.id] / 10, 108 | }, 109 | }, 110 | create: { 111 | userID: user.id, 112 | count: responseBody[user.id] / 10, 113 | }, 114 | }); 115 | 116 | // Let the user know and log the votes 117 | const dmEmbed = new EmbedBuilder() 118 | .setColor(config.colors.receipt) 119 | .setTitle("Thanks for voting!") 120 | .setDescription( 121 | `You received ${config.dtsEmoji}${responseBody[user.id]} for voting!` 122 | ) 123 | .setAuthor({ 124 | name: client.user.username, 125 | iconURL: client.user.displayAvatarURL(), 126 | }) 127 | .setTimestamp(Date.now()); 128 | 129 | const dmChannel = await user.createDM().catch(() => null); 130 | if (dmChannel) dmChannel.send({ embeds: [dmEmbed] }).catch(() => null); 131 | 132 | client.log( 133 | `:ballot_box: ${user.username} (${user.id}) received ${ 134 | config.dtsEmoji 135 | }${responseBody[user.id]} from voting.` 136 | ); 137 | } 138 | }); 139 | } 140 | 141 | // Job to give out weekly VIP voting prize 142 | scheduleJob("0 20 * * 0", async () => { 143 | const prizeMonths = 1; 144 | const givenToTop = 6; 145 | 146 | const topVoters = await db.votes.findMany({ 147 | orderBy: { 148 | count: "desc", 149 | }, 150 | take: givenToTop, 151 | }); 152 | 153 | if (topVoters.length === 0) { 154 | return client.log( 155 | `<:blobsad:386228996486070272> No qualifying voters this week.` 156 | ); 157 | } 158 | 159 | const usernames: string[] = []; 160 | 161 | for (const voteDescription of topVoters) { 162 | const user = await client.getUser(voteDescription.userID).catch(() => null); 163 | if (!user) { 164 | client.sendCrossShard( 165 | `<@${config.supportGuild.roles.customerSupport}> couldn't fetch vote winner ${voteDescription.userID}`, 166 | config.supportGuild.channels.badLogs 167 | ); 168 | } 169 | 170 | usernames.push(user?.username || "Unknown"); 171 | 172 | await db.accounts.upsert({ 173 | where: { 174 | id: voteDescription.userID, 175 | }, 176 | update: { 177 | vipMonthsRemaining: { 178 | increment: prizeMonths, 179 | }, 180 | }, 181 | create: { 182 | id: voteDescription.userID, 183 | vipMonthsRemaining: prizeMonths, 184 | }, 185 | }); 186 | 187 | if (!user) return; 188 | 189 | const dmChannel = await user.createDM().catch(() => null); 190 | if (!dmChannel) continue; 191 | 192 | const winningEmbed = new EmbedBuilder() 193 | .setColor(config.colors.vip) 194 | .setTitle("Congratulations!") 195 | .setDescription( 196 | `You have received ${prizeMonths} VIP Month(s) for being ${ 197 | topVoters.length === 1 ? "the" : "a" 198 | } highest voter this week.` 199 | ) 200 | .setFooter({ 201 | text: "You can now make any number of your choice VIP by dialing *411 and selecting VIP Options.", 202 | }); 203 | 204 | user.send({ embeds: [winningEmbed] }).catch(() => null); 205 | } 206 | 207 | // Delete all votes 208 | await db.votes.deleteMany({}); 209 | 210 | const announcementEmbed = new EmbedBuilder() 211 | .setColor(config.colors.vip) 212 | .setTitle("This week's top voters") 213 | .setDescription( 214 | `The voter(s) who voted the most have been awarded ${prizeMonths} VIP month${ 215 | prizeMonths == 1 ? "" : "s" 216 | }.` 217 | ) 218 | .addFields([ 219 | { 220 | name: topVoters.length === 1 ? "Winner" : "Winners", 221 | value: usernames.map((u) => `-${u}`).join("\n"), 222 | }, 223 | { 224 | name: "Want to win?", 225 | value: `Make sure to [vote for us on these sites](${config.voteLink})! (You also get free ${config.dtsEmoji} for voting.)`, 226 | }, 227 | ]) 228 | .setFooter({ 229 | text: `Note that bosses do not quality for the prize.`, 230 | }); 231 | 232 | if (topVoters.length === 1) { 233 | const firstUser = await client 234 | .getUser(topVoters[0].userID) 235 | .catch(() => null); 236 | if (firstUser) { 237 | announcementEmbed.setAuthor({ 238 | name: firstUser.username, 239 | iconURL: firstUser.displayAvatarURL(), 240 | }); 241 | } 242 | } 243 | 244 | announcementEmbed.addFields([ 245 | { 246 | name: `Top ${givenToTop}`, 247 | value: topVoters 248 | .map( 249 | (v, i) => 250 | `${i + 1}. ${v.count} vote${v.count == 1 ? "" : "s"} - ${ 251 | usernames[i] 252 | }` 253 | ) 254 | .join("\n"), 255 | }, 256 | ]); 257 | 258 | try { 259 | const res = await client.sendCrossShard( 260 | { embeds: [announcementEmbed] }, 261 | config.supportGuild.channels.announcement 262 | ); 263 | await client.rest.post( 264 | `/channels/${config.supportGuild.channels.announcement}/messages/${res.id}/crosspost` 265 | ); 266 | } catch (e) { 267 | console.error(e); 268 | client.sendCrossShard( 269 | `<@${config.supportGuild.roles.customerSupport}> Couldn't send voting leaderboard announcement.`, 270 | config.supportGuild.channels.badLogs 271 | ); 272 | } 273 | }); 274 | 275 | // Job to delete stored messages of calls. 276 | scheduleJob("0 0 0 * * *", async () => { 277 | const date = new Date(); 278 | 279 | // start deleting after 2 days, 5 day buffer to ensure none accidentally left. 280 | const beginDate = new Date().setDate(date.getDate() - 7); 281 | const endDate = new Date().setDate(date.getDate() - 2); 282 | 283 | const calls = ( 284 | (await db.archivedCalls.aggregateRaw({ 285 | pipeline: [ 286 | { 287 | $match: { 288 | ended: { 289 | at: { 290 | $gt: beginDate, 291 | }, 292 | }, 293 | }, 294 | }, 295 | { 296 | $match: { 297 | ended: { 298 | at: { 299 | $lt: endDate, 300 | }, 301 | }, 302 | }, 303 | }, 304 | ], 305 | })) as unknown as { _id: string }[] 306 | ).map((c) => c._id); 307 | 308 | const result = await db.callMessages.deleteMany({ 309 | where: { 310 | callID: { 311 | in: calls, 312 | }, 313 | }, 314 | }); 315 | 316 | client.log(`📖 Cleared ${result.count} messages from ${calls.length} calls.`); 317 | }); 318 | -------------------------------------------------------------------------------- /src/internals/modalProcessor.ts: -------------------------------------------------------------------------------- 1 | // File needs a better name 2 | 3 | import { ModalSubmitInteraction } from "discord.js"; 4 | import DTelClient from "./client"; 5 | import Processor from "./processor"; 6 | import i18n, { TFunction } from "i18next"; 7 | import CommandDataInterface from "../interfaces/commandData"; 8 | 9 | abstract class ModalProcessor extends Processor { 10 | interaction: ModalSubmitInteraction; 11 | t: TFunction; 12 | 13 | constructor(client: DTelClient, interaction: ModalSubmitInteraction, commandData: CommandDataInterface) { 14 | super(client, interaction, commandData); 15 | this.interaction = interaction; 16 | 17 | this.t = i18n.getFixedT(interaction.locale, undefined, `commands.${interaction.customId.split("-")[0]}`); 18 | } 19 | abstract run(): void; 20 | } 21 | export default ModalProcessor; 22 | -------------------------------------------------------------------------------- /src/internals/processor.ts: -------------------------------------------------------------------------------- 1 | // TODO: Localize (use this.t) 2 | import { CommandInteraction, InteractionResponse, MessageComponentInteraction, ModalSubmitInteraction, PermissionsBitField } from "discord.js"; 3 | import DTelClient from "./client"; 4 | import config from "../config/config"; 5 | import CommandDataInterface, { CommandType, PermissionLevel } from "../interfaces/commandData"; 6 | import { Numbers, Accounts, Mailbox } from "@prisma/client"; 7 | import { db } from "../database/db"; 8 | import CallClient from "./callClient"; 9 | import { fetchNumber, formatShardNumber, getOrCreateAccount, getUsername } from "./utils"; 10 | import { getFixedT, TFunction } from "i18next"; 11 | 12 | export type ChannelBasedInteraction = CommandInteraction|MessageComponentInteraction|ModalSubmitInteraction; 13 | 14 | abstract class Processor { 15 | config = config; 16 | 17 | client: DTelClient; 18 | db = db; 19 | interaction: T; 20 | commandData: CommandDataInterface; 21 | number: Numbers | null = null; 22 | account: Accounts | null = null; 23 | 24 | call?: CallClient; 25 | abstract t: TFunction; 26 | genericT: TFunction; 27 | 28 | constructor(client: DTelClient, interaction: T, commandData: CommandDataInterface) { 29 | this.client = client; 30 | this.interaction = interaction; 31 | this.commandData = commandData; 32 | 33 | this.genericT = getFixedT(interaction.locale, undefined, "generic"); 34 | } 35 | 36 | permCheckFail(): Promise { 37 | return this.interaction.reply({ 38 | ephemeral: true, 39 | embeds: [{ 40 | color: 0xFF0000, 41 | title: ":x: No permission!", 42 | description: "You do not have permission run do this.\nGet someone with the `MANAGE_SERVER` permission to run it for you.", 43 | }], 44 | }); 45 | } 46 | 47 | async getPerms(userID = this.interaction.user.id): Promise { 48 | let userPermissions = await this.client.getPerms(userID) as PermissionLevel; 49 | const isServerAdmin = this.interaction.channel?.isDMBased() || (this.interaction.member!.permissions as PermissionsBitField).has(PermissionsBitField.Flags.ManageGuild); 50 | 51 | if (isServerAdmin && userPermissions as number < PermissionLevel.customerSupport) { 52 | userPermissions = PermissionLevel.serverAdmin; 53 | } 54 | 55 | return userPermissions; 56 | } 57 | 58 | abstract run(): void; 59 | 60 | async fetchNumber(number?: string): Promise { 61 | return fetchNumber(number || this.interaction.channelId!); 62 | } 63 | 64 | async fetchAccount(userID = this.interaction.user.id): Promise { 65 | return getOrCreateAccount(userID); 66 | } 67 | 68 | async fetchMailbox(number: string = this.number!.number): Promise { 69 | return this.db.mailbox.upsert({ 70 | create: { 71 | number, 72 | }, 73 | where: { 74 | number, 75 | }, 76 | update: {}, 77 | }); 78 | } 79 | 80 | async _run(): Promise { 81 | if (this.commandData.useType === CommandType.call) { 82 | this.call = this.client.calls.find(c => c.from.channelID === this.interaction.channelId || c.to.channelID === this.interaction.channelId); 83 | if (!this.call) { 84 | await this.noCallFound(); 85 | return; 86 | } 87 | } else { 88 | if (this.commandData.numberRequired) { 89 | this.number = await this.fetchNumber(); 90 | if (!this.number) { 91 | await this.noNumberFound(); 92 | return; 93 | } 94 | } 95 | if (this.commandData.accountRequired) { 96 | this.account = await this.fetchAccount(); 97 | } 98 | } 99 | this.run(); 100 | } 101 | 102 | noNumberFound(): Promise { 103 | return this.interaction.reply({ 104 | ephemeral: true, 105 | embeds: [{ 106 | color: 0xFF0000, 107 | title: ":x: Error!", 108 | description: "You need a number to do this. Ask an admin to run `/wizard` to get one.", 109 | }], 110 | }); 111 | } 112 | 113 | noCallFound(): Promise { 114 | return this.interaction.reply({ 115 | ephemeral: true, 116 | embeds: [this.client.errorEmbed("This command only works when in a call. Why not call someone using `/call`?")], 117 | }); 118 | } 119 | noAccount(): Promise { 120 | return this.interaction.reply({ 121 | embeds: [this.client.errorEmbed("That user doesn't have an account.")], 122 | ephemeral: true, 123 | }); 124 | } 125 | 126 | notMaintainer(): Promise { 127 | return this.interaction.reply({ 128 | embeds: [{ 129 | color: 0xFF0000, 130 | title: ":x: No permission!", 131 | description: "You must be a maintainer to execute this command!", 132 | }], 133 | }); 134 | } 135 | 136 | guildOnly(): Promise { 137 | return this.interaction.reply({ 138 | embeds: [this.client.errorEmbed("This command can only be ran in a server!")], 139 | }); 140 | } 141 | 142 | numberShouldStartWith(): string { 143 | return this.interaction.guild ? `03${formatShardNumber(Number(process.env.SHARDS))}` : "0900"; 144 | } 145 | 146 | targetUserNotFound(): Promise { 147 | return this.interaction.reply({ 148 | ephemeral: true, 149 | embeds: [this.client.errorEmbed(this.t("errors.userNotFound"))], 150 | }); 151 | } 152 | 153 | notInSupportGuild(): Promise { 154 | return this.interaction.reply({ 155 | ephemeral: true, 156 | embeds: [this.client.errorEmbed("This command cannot be ran outside of the support server.")], 157 | }); 158 | } 159 | 160 | get userDisplayName() { 161 | return getUsername(this.interaction.user); 162 | } 163 | } 164 | export default Processor; 165 | -------------------------------------------------------------------------------- /src/internals/utils.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable arrow-body-style */ 2 | // Stuff that's too specific to but put on the client, but still used in multiple places 3 | 4 | import { Accounts, Numbers } from "@prisma/client"; 5 | import dayjs from "dayjs"; 6 | import { db } from "../database/db"; 7 | import { User } from "discord.js"; 8 | 9 | export const formatShardNumber = (shardNumber: number): string => shardNumber < 10 ? `0${shardNumber}` : shardNumber.toString(); 10 | export const formatBalance = (balance: number): string => { 11 | // For Discoin decimal compatibility 12 | const roundedBal = Math.round(balance * 100) / 100; 13 | 14 | // Adds 0s to decimal values 15 | // eslint-disable-next-line @typescript-eslint/no-extra-parens 16 | return roundedBal.toLocaleString("en-US", { minimumFractionDigits: (roundedBal % 1 < 0) ? 2 : 0 }); 17 | }; 18 | 19 | 20 | // We should move this to RE2 if someone can do it well 21 | // safe-regex says these are ok 22 | export const parseNumber = (input: string): string => input 23 | .replace(/(a|b|c)/ig, "2") 24 | .replace(/(d|e|f)/ig, "3") 25 | .replace(/(g|h|i)/ig, "4") 26 | .replace(/(j|k|l)/ig, "5") 27 | .replace(/(m|n|o)/ig, "6") 28 | .replace(/(p|q|r|s)/ig, "7") 29 | .replace(/(t|u|v)/ig, "8") 30 | .replace(/(w|x|y|z)/ig, "9") 31 | .replace(/-/ig, "") 32 | .replace(/("("|")")/ig, "") 33 | .replace(/\s+/g, ""); 34 | 35 | 36 | export const getAccount = async(id: string): Promise => { 37 | return db.accounts.findUnique({ 38 | where: { 39 | id, 40 | }, 41 | }); 42 | }; 43 | 44 | export const getOrCreateAccount = async(id: string): Promise => { 45 | let account = await getAccount(id); 46 | 47 | if (!account) { 48 | account = await db.accounts.create({ 49 | data: { 50 | id, 51 | }, 52 | }); 53 | } 54 | 55 | // We can be sure there's an account here 56 | return account!; 57 | }; 58 | 59 | export const randomString = (length: number): string => { 60 | const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; 61 | let result = ""; 62 | for (let i = 0; i < length; i++) { 63 | result += chars.charAt(Math.floor(Math.random() * chars.length)); 64 | } 65 | return result; 66 | }; 67 | 68 | export const fetchNumber = (input: string): Promise => { 69 | return db.numbers.findUnique({ 70 | where: { 71 | number: input.length === 11 ? input : undefined, 72 | channelID: input.length > 11 ? input : undefined, 73 | }, 74 | }); 75 | }; 76 | 77 | export const formatDate = (date: Date) => dayjs(date).format("YYYY-MM-DD"); 78 | export const upperFirst = (text: string) => `${text[0].toUpperCase()}${text.slice(1, text.length)}`; 79 | 80 | export const getUsername = (user: User) => { 81 | if (user.discriminator == "0") return `${user.username}`; 82 | return `${user.username}#${user.discriminator}}`; 83 | }; 84 | -------------------------------------------------------------------------------- /src/internationalization/folder.md: -------------------------------------------------------------------------------- 1 | # src/internationalization 2 | Do not change this folder name. I want people to suffer. -------------------------------------------------------------------------------- /src/internationalization/i18n.ts: -------------------------------------------------------------------------------- 1 | import English from "./data/english"; 2 | 3 | export default { 4 | en: { 5 | translation: English, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /target/npmlist.json: -------------------------------------------------------------------------------- 1 | {"version":"4.0.0","name":"dtel","dependencies":{"@discoin/scambio":{"version":"2.2.0"},"@prisma/client":{"version":"4.5.0"},"bufferutil":{"version":"4.0.7"},"dayjs":{"version":"1.11.6"},"discord-api-types":{"version":"0.37.15"},"discord.js":{"version":"14.6.0"},"erlpack":{"version":"0.1.3"},"i18next":{"version":"21.10.0"},"node-schedule":{"version":"2.1.0"},"re2":{"version":"1.17.7"},"url-regex-safe":{"version":"3.0.0"},"utf-8-validate":{"version":"5.0.10"},"uuid":{"version":"9.0.0"},"winston-daily-rotate-file":{"version":"4.7.1"},"winston":{"version":"3.8.2"},"zlib-sync":{"version":"0.1.7"},"zucc":{"version":"0.1.2"}}} -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "outDir": "./build/", 5 | "sourceMap": true, 6 | "target": "ES2020", 7 | "module": "CommonJS", 8 | "moduleResolution": "Node", 9 | "esModuleInterop": true, 10 | "skipLibCheck": true 11 | }, 12 | 13 | "include": [ 14 | "src/**/*.ts", 15 | "types/**/*.d.ts" 16 | ] 17 | } --------------------------------------------------------------------------------