├── .commitlintrc.json ├── .env.example ├── .eslintrc.json ├── .github └── workflows │ ├── validate.yml │ └── validateWithLinks.yml ├── .gitignore ├── .husky ├── .gitignore ├── commit-msg └── pre-commit ├── .lintstagedrc.json ├── .prettierrc.json ├── .vscode └── tags.code-snippets ├── .yarn ├── patches │ └── discordjs-docs-parser-npm-1.3.2-9604fe1e18.patch └── releases │ └── yarn-3.5.0.cjs ├── .yarnrc.yml ├── Dockerfile ├── LICENSE ├── PRIVACY.md ├── README.md ├── TERMS.md ├── docker-compose.yml ├── package.json ├── src ├── deployFunctions │ ├── auxtypes.ts │ ├── deploy.ts │ ├── deployDev.ts │ └── deployGlobal.ts ├── functions │ ├── algoliaResponse.ts │ ├── autocomplete │ │ ├── algoliaAutoComplete.ts │ │ ├── docsAutoComplete.ts │ │ ├── mdnAutoComplete.ts │ │ ├── nodeAutoComplete.ts │ │ └── tagAutoComplete.ts │ ├── components │ │ └── testTagButton.ts │ ├── docs.ts │ ├── mdn.ts │ ├── modals │ │ └── testTagModalSubmit.ts │ ├── node.ts │ ├── tag.ts │ └── testtag.ts ├── handling │ ├── handleApplicationCommand.ts │ ├── handleApplicationCommandAutocomplete.ts │ ├── handleComponents.ts │ └── handleModalSubmit.ts ├── index.ts ├── interactions │ ├── discorddocs.ts │ ├── discordtypes.ts │ ├── docs.ts │ ├── guide.ts │ ├── mdn.ts │ ├── node.ts │ ├── reloadVersioncache.ts │ ├── tag.ts │ ├── tagreload.ts │ └── testtag.ts ├── types │ ├── NodeDocs.d.ts │ ├── algolia.ts │ └── mdn.ts ├── util │ ├── argumentsOf.ts │ ├── compactAlgoliaId.ts │ ├── constants.ts │ ├── dedupe.ts │ ├── discordDocs.ts │ ├── djsdocs.ts │ ├── interactionOptions.ts │ ├── jsonParser.ts │ ├── logger.ts │ ├── misc.ts │ ├── respond.ts │ ├── truncate.ts │ └── url.ts └── workflowFunctions │ ├── validateTags.ts │ ├── validateTagsWithLinks.ts │ └── validateTagsWithoutLinks.ts ├── tags └── tags.toml ├── tsconfig.eslint.json ├── tsconfig.json └── yarn.lock /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-angular"], 3 | "rules": { 4 | "type-enum": [ 5 | 2, 6 | "always", 7 | ["chore", "build", "ci", "docs", "feat", "fix", "perf", "refactor", "revert", "style", "test", "types", "wip"] 8 | ] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | PORT=49666 2 | ENVIRONMENT= 3 | 4 | DJS_GUIDE_ALGOLIA_APP= 5 | DJS_GUIDE_ALGOLIA_KEY= 6 | DDOCS_ALGOLIA_APP= 7 | DDOCS_ALGOLIA_KEY= 8 | DTYPES_ALGOLIA_APP= 9 | DTYPES_ALGOLIA_KEY= 10 | 11 | ORAMA_KEY= 12 | ORAMA_ID= 13 | ORAMA_CONTAINER= 14 | 15 | DJS_DOCS_BEARER= 16 | 17 | DISCORD_PUBKEY= 18 | DISCORD_CLIENT_ID= 19 | DISCORD_TOKEN= 20 | DISCORD_DEVGUILD_ID= 21 | 22 | CF_STORAGE_BASE= 23 | CF_ACCOUNT_ID= 24 | CF_D1_DOCS_ID= 25 | CF_D1_DOCS_API_KEY= 26 | 27 | ENVIRONMENT=debug -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["neon/common", "neon/node", "neon/typescript", "neon/prettier"], 3 | "parserOptions": { 4 | "project": "./tsconfig.eslint.json" 5 | }, 6 | "rules": { 7 | "@typescript-eslint/naming-convention": 0, 8 | "prefer-named-capture-group": 0, 9 | "no-console": 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | on: [push, pull_request] 3 | jobs: 4 | validate: 5 | name: validate 6 | runs-on: macos-latest 7 | steps: 8 | - name: Checkout repository 9 | uses: actions/checkout@v4 10 | 11 | - name: Install node20 runtime 12 | uses: actions/setup-node@v4 13 | with: 14 | node-version: 20 15 | 16 | - name: Install dependencies 17 | run: rm -rf node_modules && yarn install --frozen-lockfile 18 | 19 | - name: Run ESLint 20 | run: yarn lint 21 | 22 | - name: Run TSC 23 | run: yarn build 24 | 25 | - name: Validate Tags 26 | run: yarn validate-tags 27 | -------------------------------------------------------------------------------- /.github/workflows/validateWithLinks.yml: -------------------------------------------------------------------------------- 1 | name: Validate with link responses 2 | on: [workflow_dispatch] 3 | 4 | jobs: 5 | validate: 6 | name: validate 7 | runs-on: macos-latest 8 | steps: 9 | - name: Checkout repository 10 | uses: actions/checkout@v4 11 | 12 | - name: Install node20 runtime 13 | uses: actions/setup-node@v4 14 | with: 15 | node-version: 20 16 | 17 | - name: Install dependencies 18 | run: rm -rf node_modules && yarn install --frozen-lockfile 19 | 20 | - name: Run ESLint 21 | run: yarn lint 22 | 23 | - name: Run TSC 24 | run: yarn build 25 | 26 | - name: Validate Tags 27 | run: yarn validate-tags:withlinks 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # secrets 2 | .env 3 | 4 | # build flow 5 | dist/ 6 | node_modules/ 7 | 8 | # miscellaneous 9 | .DS_Store 10 | .yarn/* 11 | !.yarn/releases 12 | !.yarn/patches -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit $1 -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install lint-staged 5 | npm run build && npm run validate-tags -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.{mjs,js}": "eslint --fix --ext mjs,js,ts", 3 | "*.{ts,json,yml,yaml,toml}": "prettier --write" 4 | } 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "useTabs": true, 4 | "singleQuote": true, 5 | "quoteProps": "as-needed", 6 | "trailingComma": "all", 7 | "endOfLine": "lf", 8 | "plugins": ["prettier-plugin-toml"] 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/tags.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "tag": { 3 | "prefix": [ 4 | "tag", 5 | "t" 6 | ], 7 | "description": "A new tag", 8 | "body": [ 9 | "[$1]", 10 | "keywords = [\"$2\"]", 11 | "content = \"\"\"", 12 | "$3", 13 | "\"\"\"", 14 | "", 15 | "" 16 | ] 17 | }, 18 | "arrow": { 19 | "prefix": [ 20 | "arrow", 21 | "a" 22 | ], 23 | "description": "Just an arrow ➞", 24 | "body": "➞" 25 | }, 26 | "link": { 27 | "prefix": [ 28 | "link", 29 | "l" 30 | ], 31 | "description": "Markdown link", 32 | "body": "[$1]($2)" 33 | }, 34 | "learnmore": { 35 | "prefix": [ 36 | "more", 37 | "external" 38 | ], 39 | "description": "Markdown link for extended resources", 40 | "body": "[learn more]($2)" 41 | } 42 | } -------------------------------------------------------------------------------- /.yarn/patches/discordjs-docs-parser-npm-1.3.2-9604fe1e18.patch: -------------------------------------------------------------------------------- 1 | diff --git a/dist/index.js b/dist/index.js 2 | index 771881f285015f9a172c467f995da6b5e06984e5..16195d2d2e4c7f9d0bf3d989561fb8fc230392b1 100644 3 | --- a/dist/index.js 4 | +++ b/dist/index.js 5 | @@ -475,7 +475,7 @@ var _Doc = class extends DocBase { 6 | /** 7 | * The documentation base URL. 8 | */ 9 | - __publicField(this, "baseURL", "https://discord.js.org"); 10 | + __publicField(this, "baseURL", "https://old.discordjs.dev"); 11 | /** 12 | * The project dissected from the {@link url}. 13 | */ 14 | diff --git a/dist/index.mjs b/dist/index.mjs 15 | index 8ac6d1cae821f6a004441fc24332f0f5f75f82a2..d0bdaa9b295c398dc83e711ff324fa1b6cde0505 100644 16 | --- a/dist/index.mjs 17 | +++ b/dist/index.mjs 18 | @@ -439,7 +439,7 @@ var _Doc = class extends DocBase { 19 | /** 20 | * The documentation base URL. 21 | */ 22 | - __publicField(this, "baseURL", "https://discord.js.org"); 23 | + __publicField(this, "baseURL", "https://old.discordjs.dev"); 24 | /** 25 | * The project dissected from the {@link url}. 26 | */ 27 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | yarnPath: .yarn/releases/yarn-3.5.0.cjs 2 | 3 | nodeLinker: node-modules 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine 2 | LABEL name "slash-utils" 3 | LABEL version "0.0.0" 4 | LABEL maintainer "almostSouji " 5 | ENV FORCE_COLOR=1 6 | WORKDIR /usr/slash-utils 7 | COPY package.json ./ 8 | COPY . . 9 | RUN yarn build 10 | CMD ["yarn", "start"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | Copyright 2021 almostSouji 179 | 180 | Licensed under the Apache License, Version 2.0 (the "License"); 181 | you may not use this file except in compliance with the License. 182 | You may obtain a copy of the License at 183 | 184 | http://www.apache.org/licenses/LICENSE-2.0 185 | 186 | Unless required by applicable law or agreed to in writing, software 187 | distributed under the License is distributed on an "AS IS" BASIS, 188 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 189 | See the License for the specific language governing permissions and 190 | limitations under the License. -------------------------------------------------------------------------------- /PRIVACY.md: -------------------------------------------------------------------------------- 1 |
2 |
3 |

4 | discord.js 5 |

6 |
7 |

8 | Discord server 9 |

10 |
11 | 12 | # Privacy Policy 13 | 14 | This is a HTTP interaction command-only Discord application and does not read any message you send, unless you provide it as an argument to one of its commands. Adding the `bot` scope to your server does not yield any benefit. This application does not have a WebSocket connection and does not read any events. 15 | 16 | If you contribute tags via the GitHub repository, you agree that the provided content is persisted and shown to users on any Discord server with no attribution save the GitHub repository and git history. 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |
3 |

4 | discord.js 5 |

6 |
7 |

8 | Discord server 9 |

10 |
11 | 12 | # Contributing tags 13 | 14 | New tags are added via pull requests. Please provide the tag content as PR description and `Tag: ` followed by the tag name as pull request title. 15 | 16 | Add new tags in `./tags/tags.toml` in the following format: 17 | 18 | ```toml 19 | [tag-name] 20 | keywords = ["keyword", "another-keyword"] 21 | content = """ 22 | Put your tag content here! 23 | """ 24 | 25 | ``` 26 | 27 | - Tag names and keywords have to use `-` instead of spaces 28 | - Backslashes have to be escaped! `\\` 29 | - Code blocks work and newlines are respected 30 | - The application uses slash command interactions only, you can use emojis from the Discord server (please do not use global emojis from other servers, as we can't control them being deleted at any point) 31 | - You can use masked link syntax `[discord.js](https://discord.js.org 'discord.js website')` (links do not need to be escaped because of the message flag set on all responses). 32 | - The repository includes vscode code-snippets for tags, "learn more" links, and an arrow character.. 33 | - You can test tags through the bot (for example in our [discord server](https://discord.gg/djs)) with `/testtag`. 34 | -------------------------------------------------------------------------------- /TERMS.md: -------------------------------------------------------------------------------- 1 |
2 |
3 |

4 | discord.js 5 |

6 |
7 |

8 | Discord server 9 |

10 |
11 | 12 | # Terms of Use 13 | 14 | ## Do not be purposefully disruptive 15 | 16 | - Keep mentions contextually relevant 17 | - Do not spam non-bot channels and disrupt conversations 18 | - Do not contribute abusive tags 19 | - Only contribute high-quality changes 20 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | application: 5 | build: 6 | context: ./ 7 | dockerfile: Dockerfile 8 | restart: always 9 | env_file: 10 | - ./.env 11 | ports: 12 | - '49200:49666' 13 | volumes: 14 | - type: bind 15 | source: ./tags 16 | target: /usr/slash-utils/tags 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slash-utils", 3 | "version": "0.0.0", 4 | "description": "Slash command utilities for the discord.js support server.", 5 | "scripts": { 6 | "build": "rimraf dist && tsc", 7 | "start": "node --enable-source-maps dist/index.js", 8 | "lint": "eslint src --ext .ts", 9 | "lint:fix": "eslint src --ext .ts --fix", 10 | "prettier": "prettier --write **/*.{ts,js,toml}", 11 | "cmd:glob": "env-cmd node dist/deployFunctions/deployGlobal.js", 12 | "cmd:dev": "env-cmd node dist/deployFunctions/deployDev.js", 13 | "validate-tags": "node dist/workflowFunctions/validateTagsWithoutLinks.js", 14 | "validate-tags:withlinks": "node dist/workflowFunctions/validateTagsWithLinks.js", 15 | "prepare": "is-ci || husky install" 16 | }, 17 | "type": "module", 18 | "main": "dist/index.js", 19 | "license": "Apache-2.0", 20 | "private": true, 21 | "author": "Souji ", 22 | "keywords": [ 23 | "discord", 24 | "webhook", 25 | "discordapp", 26 | "discord.js", 27 | "slashcommand", 28 | "utilities" 29 | ], 30 | "dependencies": { 31 | "@discordjs/builders": "^1.8.0", 32 | "@discordjs/collection": "^2.1.0", 33 | "@discordjs/rest": "^2.3.0", 34 | "@hapi/boom": "^10.0.1", 35 | "@ltd/j-toml": "^1.38.0", 36 | "algoliasearch": "^4.19.1", 37 | "cheerio": "^1.0.0-rc.12", 38 | "cloudflare": "^4.2.0", 39 | "discord-api-types": "^0.37.83", 40 | "dotenv": "^16.3.1", 41 | "he": "^1.2.0", 42 | "html-entities": "^2.4.0", 43 | "kleur": "^4.1.5", 44 | "pino": "^8.15.0", 45 | "polka": "1.0.0-next.15", 46 | "readdirp": "^3.6.0", 47 | "reflect-metadata": "^0.2.2", 48 | "turndown": "^7.1.2", 49 | "undici": "^6.21.1" 50 | }, 51 | "devDependencies": { 52 | "@commitlint/cli": "^17.7.1", 53 | "@commitlint/config-angular": "^17.7.0", 54 | "@types/he": "^1.2.0", 55 | "@types/node": "20.5.7", 56 | "@types/pino": "^7.0.5", 57 | "@types/turndown": "^5.0.1", 58 | "@typescript-eslint/eslint-plugin": "^6.5.0", 59 | "@typescript-eslint/parser": "^6.5.0", 60 | "env-cmd": "^10.1.0", 61 | "eslint": "^8.48.0", 62 | "eslint-config-neon": "0.1.54", 63 | "eslint-config-prettier": "^9.0.0", 64 | "eslint-plugin-prettier": "^5.0.0", 65 | "husky": "^8.0.3", 66 | "lint-staged": "^15.2.10", 67 | "prettier": "^3.0.2", 68 | "prettier-plugin-toml": "^1.0.0", 69 | "rimraf": "^5.0.1", 70 | "tsyringe": "^4.8.0", 71 | "typescript": "^5.2.2" 72 | }, 73 | "engines": { 74 | "node": ">=20.0.0" 75 | }, 76 | "packageManager": "yarn@3.5.0" 77 | } 78 | -------------------------------------------------------------------------------- /src/deployFunctions/auxtypes.ts: -------------------------------------------------------------------------------- 1 | export enum PreReleaseApplicationCommandContextType { 2 | Guild, 3 | BotDm, 4 | PrivateChannel, 5 | } 6 | 7 | export enum PreReleaseApplicationIntegrationType { 8 | GuildInstall, 9 | UserInstall, 10 | } 11 | -------------------------------------------------------------------------------- /src/deployFunctions/deploy.ts: -------------------------------------------------------------------------------- 1 | import { resolve, dirname } from 'node:path'; 2 | import process from 'node:process'; 3 | import { fileURLToPath } from 'node:url'; 4 | import { config } from 'dotenv'; 5 | import { fetch } from 'undici'; 6 | import { API_BASE_DISCORD } from '../util/constants.js'; 7 | import { logger } from '../util/logger.js'; 8 | import { PreReleaseApplicationCommandContextType, PreReleaseApplicationIntegrationType } from './auxtypes.js'; 9 | 10 | config({ path: resolve(dirname(fileURLToPath(import.meta.url)), '../../.env') }); 11 | 12 | export async function deploy(data: any, dev = false) { 13 | const midRoute = dev ? `/guilds/${process.env.DISCORD_DEVGUILD_ID!}` : ''; 14 | const route = `${API_BASE_DISCORD}/applications/${process.env.DISCORD_CLIENT_ID!}${midRoute}/commands`; 15 | 16 | try { 17 | logger.info(`Starting update on route ${route}`); 18 | const res = await fetch(route, { 19 | headers: { 20 | 'Content-Type': 'application/json', 21 | Authorization: `Bot ${process.env.DISCORD_TOKEN!}`, 22 | }, 23 | method: 'put', 24 | body: JSON.stringify( 25 | dev 26 | ? data 27 | : data.map((command: any) => ({ 28 | ...command, 29 | integration_types: [ 30 | PreReleaseApplicationIntegrationType.UserInstall, 31 | PreReleaseApplicationIntegrationType.GuildInstall, 32 | ], 33 | contexts: [ 34 | PreReleaseApplicationCommandContextType.Guild, 35 | PreReleaseApplicationCommandContextType.PrivateChannel, 36 | PreReleaseApplicationCommandContextType.BotDm, 37 | ], 38 | })), 39 | ), 40 | }).then(async (response) => response.json()); 41 | logger.info(res as string); 42 | logger.info('Update completed'); 43 | } catch (error) { 44 | logger.info('Request failed:'); 45 | logger.error(error as Error); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/deployFunctions/deployDev.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import { DjsVersionReloadCommand } from '../interactions/reloadVersioncache.js'; 3 | import { TagReloadCommand } from '../interactions/tagreload.js'; 4 | import { deploy } from './deploy.js'; 5 | 6 | void deploy( 7 | [DjsVersionReloadCommand, TagReloadCommand].map((interaction) => ({ 8 | ...interaction, 9 | description: `🛠️ ${interaction.description}`, 10 | })), 11 | true, 12 | ); 13 | -------------------------------------------------------------------------------- /src/deployFunctions/deployGlobal.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import { DiscordDocsCommand } from '../interactions/discorddocs.js'; 3 | import { DTypesCommand } from '../interactions/discordtypes.js'; 4 | import { DocsCommand } from '../interactions/docs.js'; 5 | import { GuideCommand } from '../interactions/guide.js'; 6 | import { MdnCommand } from '../interactions/mdn.js'; 7 | import { NodeCommand } from '../interactions/node.js'; 8 | import { TagCommand } from '../interactions/tag.js'; 9 | import { TestTagCommand } from '../interactions/testtag.js'; 10 | import { deploy } from './deploy.js'; 11 | 12 | const staticGlobalCommands = [ 13 | DiscordDocsCommand, 14 | GuideCommand, 15 | MdnCommand, 16 | NodeCommand, 17 | TagCommand, 18 | TestTagCommand, 19 | DTypesCommand, 20 | ]; 21 | 22 | void deploy([...staticGlobalCommands, DocsCommand]); 23 | -------------------------------------------------------------------------------- /src/functions/algoliaResponse.ts: -------------------------------------------------------------------------------- 1 | import { hideLinkEmbed, hyperlink, userMention, italic, bold, inlineCode } from '@discordjs/builders'; 2 | import pkg from 'he'; 3 | import type { Response } from 'polka'; 4 | import { fetch } from 'undici'; 5 | import type { AlgoliaHit } from '../types/algolia.js'; 6 | import { expandAlgoliaObjectId } from '../util/compactAlgoliaId.js'; 7 | import { API_BASE_ALGOLIA } from '../util/constants.js'; 8 | import { fetchDocsBody } from '../util/discordDocs.js'; 9 | import { prepareResponse, prepareErrorResponse } from '../util/respond.js'; 10 | import { truncate } from '../util/truncate.js'; 11 | import { resolveHitToNamestring } from './autocomplete/algoliaAutoComplete.js'; 12 | 13 | const { decode } = pkg; 14 | 15 | export async function algoliaResponse( 16 | res: Response, 17 | algoliaAppId: string, 18 | algoliaApiKey: string, 19 | algoliaIndex: string, 20 | algoliaObjectId: string, 21 | emojiId: string, 22 | emojiName: string, 23 | user?: string, 24 | ephemeral?: boolean, 25 | type = 'documentation', 26 | ): Promise { 27 | const full = `http://${algoliaAppId}.${API_BASE_ALGOLIA}/1/indexes/${algoliaIndex}/${encodeURIComponent( 28 | expandAlgoliaObjectId(algoliaObjectId), 29 | )}`; 30 | try { 31 | const hit = (await fetch(full, { 32 | method: 'get', 33 | headers: { 34 | 'Content-Type': 'application/json', 35 | 'X-Algolia-API-Key': algoliaApiKey, 36 | 'X-Algolia-Application-Id': algoliaAppId, 37 | }, 38 | }).then(async (res) => res.json())) as AlgoliaHit; 39 | 40 | const docsBody = hit.url.includes('discord.com') ? await fetchDocsBody(hit.url) : null; 41 | const headlineSuffix = 42 | docsBody?.heading?.verb && docsBody?.heading.route 43 | ? inlineCode(`${docsBody.heading.verb} ${docsBody.heading.route}`.replaceAll('\\', '')) 44 | : null; 45 | 46 | const contentParts = [ 47 | `<:${emojiName}:${emojiId}> ${bold(resolveHitToNamestring(hit))}${headlineSuffix ? ` ${headlineSuffix}` : ''}`, 48 | hit.content?.length ? `${truncate(decode(hit.content), 300)}` : docsBody?.lines.at(0), 49 | `${hyperlink('read more', hideLinkEmbed(hit.url))}`, 50 | ].filter(Boolean) as string[]; 51 | 52 | prepareResponse(res, contentParts.join('\n'), { 53 | ephemeral, 54 | suggestion: user ? { userId: user, kind: type } : undefined, 55 | }); 56 | } catch { 57 | prepareErrorResponse(res, 'Invalid result. Make sure to select an entry from the autocomplete.'); 58 | } 59 | 60 | return res; 61 | } 62 | -------------------------------------------------------------------------------- /src/functions/autocomplete/algoliaAutoComplete.ts: -------------------------------------------------------------------------------- 1 | import { stringify } from 'node:querystring'; 2 | import { InteractionResponseType } from 'discord-api-types/v10'; 3 | import { decode } from 'html-entities'; 4 | import type { Response } from 'polka'; 5 | import { fetch } from 'undici'; 6 | import type { AlgoliaHit, AlgoliaSearchResult } from '../../types/algolia.js'; 7 | import { compactAlgoliaObjectId } from '../../util/compactAlgoliaId.js'; 8 | import { API_BASE_ALGOLIA, AUTOCOMPLETE_MAX_ITEMS, AUTOCOMPLETE_MAX_NAME_LENGTH } from '../../util/constants.js'; 9 | import { dedupeAlgoliaHits } from '../../util/dedupe.js'; 10 | import { prepareHeader } from '../../util/respond.js'; 11 | import { truncate } from '../../util/truncate.js'; 12 | 13 | function removeDtypesPrefix(str: string | null) { 14 | return (str ?? '').replace('discord-api-types/', ''); 15 | } 16 | 17 | function compressHeading(heading: string) { 18 | return heading.toLowerCase().replaceAll(/[ ,.=_-]/g, ''); 19 | } 20 | 21 | function headingIsSimilar(one: string, other: string) { 22 | const one_ = compressHeading(one); 23 | const other_ = compressHeading(other); 24 | 25 | return one_.startsWith(other_) || other_.startsWith(one_); 26 | } 27 | 28 | export function resolveHitToNamestring(hit: AlgoliaHit) { 29 | const { hierarchy } = hit; 30 | 31 | const [lvl0, lvl1, ...restLevels] = Object.values(hierarchy) 32 | .filter(Boolean) 33 | .map((heading) => removeDtypesPrefix(heading)); 34 | 35 | const headingParts = []; 36 | 37 | if (headingIsSimilar(lvl0, lvl1)) { 38 | headingParts.push(lvl1); 39 | } else { 40 | headingParts.push(`${lvl0}:`, lvl1); 41 | } 42 | 43 | const mostSpecific = restLevels.at(-1); 44 | if (mostSpecific?.length && mostSpecific !== lvl0 && mostSpecific !== lvl1) { 45 | headingParts.push(`- ${mostSpecific}`); 46 | } 47 | 48 | return decode(headingParts.join(' '))!; 49 | } 50 | 51 | function autoCompleteMap(elements: AlgoliaHit[]) { 52 | const uniqueElements = elements.filter(dedupeAlgoliaHits()); 53 | return uniqueElements 54 | .filter((element) => { 55 | const value = compactAlgoliaObjectId(element.objectID); 56 | // API restriction. Cannot resolve from truncated, so filtering here. 57 | return value.length <= AUTOCOMPLETE_MAX_NAME_LENGTH; 58 | }) 59 | .map((element) => ({ 60 | name: truncate(resolveHitToNamestring(element), AUTOCOMPLETE_MAX_NAME_LENGTH, ''), 61 | value: compactAlgoliaObjectId(element.objectID), 62 | })); 63 | } 64 | 65 | export async function algoliaAutoComplete( 66 | res: Response, 67 | query: string, 68 | algoliaAppId: string, 69 | algoliaApiKey: string, 70 | algoliaIndex: string, 71 | ): Promise { 72 | const full = `http://${algoliaAppId}.${API_BASE_ALGOLIA}/1/indexes/${algoliaIndex}/query`; 73 | const result = (await fetch(full, { 74 | method: 'post', 75 | body: JSON.stringify({ 76 | params: stringify({ 77 | query, 78 | }), 79 | }), 80 | headers: { 81 | 'Content-Type': 'application/json', 82 | 'X-Algolia-API-Key': algoliaApiKey, 83 | 'X-Algolia-Application-Id': algoliaAppId, 84 | }, 85 | }).then(async (res) => res.json())) as AlgoliaSearchResult; 86 | 87 | prepareHeader(res); 88 | res.write( 89 | JSON.stringify({ 90 | data: { 91 | choices: autoCompleteMap(result.hits?.slice(0, AUTOCOMPLETE_MAX_ITEMS - 1) ?? []), 92 | }, 93 | type: InteractionResponseType.ApplicationCommandAutocompleteResult, 94 | }), 95 | ); 96 | 97 | return res; 98 | } 99 | -------------------------------------------------------------------------------- /src/functions/autocomplete/docsAutoComplete.ts: -------------------------------------------------------------------------------- 1 | import process, { versions } from 'node:process'; 2 | import type { 3 | APIApplicationCommandInteractionDataOption, 4 | APIApplicationCommandInteractionDataStringOption, 5 | } from 'discord-api-types/v10'; 6 | import { ApplicationCommandOptionType, InteractionResponseType } from 'discord-api-types/v10'; 7 | import type { Response } from 'polka'; 8 | import { AUTOCOMPLETE_MAX_ITEMS, AUTOCOMPLETE_MAX_NAME_LENGTH, DJS_QUERY_SEPARATOR } from '../../util/constants.js'; 9 | import { getCurrentMainPackageVersion, getDjsVersions } from '../../util/djsdocs.js'; 10 | import { logger } from '../../util/logger.js'; 11 | import { truncate } from '../../util/truncate.js'; 12 | 13 | /** 14 | * Transform dotted versions into meili search compatible version keys, stripping unwanted characters 15 | * (^x.y.z -\> x-y-z) 16 | * 17 | * @param version - Dotted version string 18 | * @returns The meili search compatible version 19 | */ 20 | export function meiliVersion(version: string) { 21 | return version.replaceAll('^', '').split('.').join('-'); 22 | } 23 | 24 | /** 25 | * Dissect a discord.js documentation path into its parts 26 | * 27 | * @param path - The path to parse 28 | * @returns The path parts 29 | */ 30 | export function parseDocsPath(path: string) { 31 | // /0 /1 /2 /3 /4 32 | // /docs/packages/builders/main/EmbedBuilder:Class 33 | // /docs/packages/builders/main/EmbedImageData:Interface#proxyURL 34 | 35 | const parts = path.trim().split('/').filter(Boolean); 36 | const query = parts.at(4); 37 | const queryParts = query?.split('#'); 38 | 39 | const [item, kind] = queryParts?.at(0)?.split(':') ?? []; 40 | const method = queryParts?.at(1); 41 | 42 | const _package = parts.at(2); 43 | const version = parts.at(3); 44 | 45 | return { 46 | package: _package, 47 | version, 48 | query, 49 | item, 50 | kind, 51 | method, 52 | }; 53 | } 54 | 55 | const BASE_SEARCH = 'https://search.discordjs.dev/'; 56 | 57 | export const djsDocsDependencies = new Map(); 58 | 59 | /** 60 | * Fetch the discord.js dependencies for a specific verison 61 | * Note: Tries to resolve from cache before hitting the API 62 | * Note: Information is resolved from the package.json file in the respective package root 63 | * 64 | * @param version - The version to retrieve dependencies for 65 | * @returns The package dependencies 66 | */ 67 | export async function fetchDjsDependencies(version: string) { 68 | const hit = djsDocsDependencies.get(version); 69 | const url = `${process.env.CF_STORAGE_BASE}/discord.js/${version}.dependencies.api.json`; 70 | logger.debug(`Requesting dependencies from CF: ${url}`); 71 | const dependencies = hit ?? (await fetch(url).then(async (res) => res.json())); 72 | 73 | if (!hit) { 74 | djsDocsDependencies.set(version, dependencies); 75 | } 76 | 77 | return dependencies; 78 | } 79 | 80 | /** 81 | * Fetch the version of a dependency based on a main package version and dependency package name 82 | * 83 | * @param mainPackageVersion - The main package version to use for dependencies 84 | * @param _package - The package to fetch the version for 85 | * @returns The version of the dependency package 86 | */ 87 | export async function fetchDependencyVersion(mainPackageVersion: string, _package: string) { 88 | const dependencies = await fetchDjsDependencies(mainPackageVersion); 89 | 90 | const version = Object.entries(dependencies).find(([key, value]) => { 91 | if (typeof value !== 'string') return false; 92 | 93 | const parts = key.split('/'); 94 | const packageName = parts[1]; 95 | return packageName === _package; 96 | })?.[1] as string | undefined; 97 | 98 | return version?.replaceAll('^', ''); 99 | } 100 | 101 | /** 102 | * Build Meili search queries for the base package and all its dependencies as defined in the documentation 103 | * 104 | * @param query - The query term to use across packages 105 | * @param mainPackageVersion - The version to use across packages 106 | * @returns Meili query objects for the provided parameters 107 | */ 108 | export async function buildMeiliQueries(query: string, mainPackageVersion: string) { 109 | const dependencies = await fetchDjsDependencies(mainPackageVersion); 110 | const baseQuery = { 111 | // eslint-disable-next-line id-length -- Meili search denotes the query with a "q" key 112 | q: query, 113 | limit: 25, 114 | attributesToSearchOn: ['name'], 115 | sort: ['type:asc'], 116 | }; 117 | 118 | const queries = [ 119 | { 120 | indexUid: `discord-js-${meiliVersion(mainPackageVersion)}`, 121 | ...baseQuery, 122 | }, 123 | ]; 124 | 125 | for (const [dependencyPackageIdentifier, dependencyVersion] of Object.entries(dependencies)) { 126 | if (typeof dependencyVersion !== 'string') continue; 127 | 128 | const packageName = dependencyPackageIdentifier.split('/')[1]; 129 | const parts = [...packageName.split('.'), meiliVersion(dependencyVersion)]; 130 | const indexUid = parts.join('-'); 131 | 132 | queries.push({ 133 | indexUid, 134 | ...baseQuery, 135 | }); 136 | } 137 | 138 | queries.push({ 139 | indexUid: 'voice-main', 140 | ...baseQuery, 141 | }); 142 | 143 | return queries; 144 | } 145 | 146 | /** 147 | * Remove unwanted characters from autocomplete text 148 | * 149 | * @param text - The input to sanitize 150 | * @returns The sanitized text 151 | */ 152 | function sanitizeText(text: string) { 153 | return text.replaceAll('*', ''); 154 | } 155 | 156 | /** 157 | * Search the discord.js documentation using meilisearch multi package queries 158 | * 159 | * @param query - The query term to use across packages 160 | * @param version - The main package version to use 161 | * @returns Documentation results for the provided parameters 162 | */ 163 | export async function djsMeiliSearch(query: string, version: string) { 164 | const searchResult = await fetch(`${BASE_SEARCH}multi-search`, { 165 | method: 'post', 166 | body: JSON.stringify({ 167 | queries: await buildMeiliQueries(query, version), 168 | }), 169 | headers: { 170 | 'Content-Type': 'application/json', 171 | Authorization: `Bearer ${process.env.DJS_DOCS_BEARER!}`, 172 | }, 173 | }); 174 | 175 | const docsResult = (await searchResult.json()) as any; 176 | 177 | const groupedHits = new Map(); 178 | 179 | for (const result of docsResult.results) { 180 | const index = result.indexUid; 181 | for (const hit of result.hits) { 182 | const current = groupedHits.get(hit.name); 183 | if (!current) { 184 | groupedHits.set(hit.name, [[index, hit]]); 185 | continue; 186 | } 187 | 188 | current.push([index, hit]); 189 | } 190 | } 191 | 192 | const hits = []; 193 | 194 | for (const group of groupedHits.values()) { 195 | const sorted = group.sort(([fstIndex], [sndIndex]) => { 196 | if (fstIndex.startsWith('discord-js')) { 197 | return 1; 198 | } 199 | 200 | if (sndIndex.startsWith('discord.js')) { 201 | return -1; 202 | } 203 | 204 | return 0; 205 | }); 206 | 207 | hits.push(sorted[0][1]); 208 | } 209 | 210 | return { 211 | ...docsResult, 212 | hits: hits.map((hit: any) => { 213 | const parsed = parseDocsPath(hit.path); 214 | const isMember = ['Property', 'Method', 'Event', 'PropertySignature', 'EnumMember', 'MethodSignature'].includes( 215 | hit.kind, 216 | ); 217 | const parts = [parsed.package, parsed.item.toLocaleLowerCase(), parsed.kind]; 218 | 219 | if (isMember && parsed.method) { 220 | parts.push(parsed.method); 221 | } 222 | 223 | return { 224 | ...hit, 225 | autoCompleteName: truncate( 226 | `${hit.name}${hit.summary ? ` - ${sanitizeText(hit.summary)}` : ''}`, 227 | AUTOCOMPLETE_MAX_NAME_LENGTH, 228 | ' ', 229 | ), 230 | autoCompleteValue: parts.join(DJS_QUERY_SEPARATOR), 231 | isMember, 232 | }; 233 | }), 234 | }; 235 | } 236 | 237 | /** 238 | * Handle the command reponse for the discord.js docs command autocompletion 239 | * 240 | * @param res - Reponse to write 241 | * @param options - Command options 242 | * @returns The written response 243 | */ 244 | export async function djsAutoComplete( 245 | res: Response, 246 | options: APIApplicationCommandInteractionDataOption[], 247 | ): Promise { 248 | res.setHeader('Content-Type', 'application/json'); 249 | const defaultVersion = getCurrentMainPackageVersion(); 250 | 251 | const queryOptionData = options.find((option) => option.name === 'query') as 252 | | APIApplicationCommandInteractionDataStringOption 253 | | undefined; 254 | const versionOptionData = options.find((option) => option.name === 'version') as 255 | | APIApplicationCommandInteractionDataStringOption 256 | | undefined; 257 | 258 | if (!queryOptionData) { 259 | throw new Error('expected query option, none received'); 260 | } 261 | 262 | const docsResult = await djsMeiliSearch(queryOptionData.value, versionOptionData?.value ?? defaultVersion); 263 | const choices = []; 264 | 265 | for (const hit of docsResult.hits) { 266 | if (choices.length >= AUTOCOMPLETE_MAX_ITEMS) { 267 | break; 268 | } 269 | 270 | choices.push({ 271 | name: hit.autoCompleteName, 272 | value: hit.autoCompleteValue, 273 | }); 274 | } 275 | 276 | res.write( 277 | JSON.stringify({ 278 | data: { 279 | choices, 280 | }, 281 | type: InteractionResponseType.ApplicationCommandAutocompleteResult, 282 | }), 283 | ); 284 | 285 | return res; 286 | } 287 | 288 | type DocsAutoCompleteData = { 289 | ephemeral?: boolean; 290 | mention?: string; 291 | query: string; 292 | source: string; 293 | version: string; 294 | }; 295 | 296 | /** 297 | * Resolve the required options (with appropriate fallbacks) from the received command options 298 | * 299 | * @param options - The options to resolve 300 | * @returns Resolved options 301 | */ 302 | export async function resolveOptionsToDocsAutoComplete( 303 | options: APIApplicationCommandInteractionDataOption[], 304 | ): Promise { 305 | let query = 'Client'; 306 | let version = getCurrentMainPackageVersion(); 307 | let ephemeral = false; 308 | let mention; 309 | let source = 'discord.js'; 310 | 311 | for (const opt of options) { 312 | if (opt.type === ApplicationCommandOptionType.String) { 313 | if (opt.name === 'query' && opt.value.length) { 314 | query = opt.value; 315 | 316 | if (query.includes(DJS_QUERY_SEPARATOR)) { 317 | source = query.split(DJS_QUERY_SEPARATOR)?.[0]; 318 | } else { 319 | const searchResult = await djsMeiliSearch(query, version); 320 | const bestHit = searchResult.hits[0]; 321 | 322 | if (bestHit) { 323 | source = bestHit.autoCompleteValue.split(DJS_QUERY_SEPARATOR)[0]; 324 | query = bestHit.autoCompleteValue; 325 | } 326 | } 327 | } 328 | 329 | if (opt.name === 'version' && opt.value.length) { 330 | version = opt.value; 331 | } 332 | } else if (opt.type === ApplicationCommandOptionType.Boolean && opt.name === 'hide') { 333 | ephemeral = opt.value; 334 | } else if (opt.type === ApplicationCommandOptionType.User && opt.name === 'mention') { 335 | mention = opt.value; 336 | } 337 | } 338 | 339 | if (source !== 'discord.js') { 340 | const dependencyVersion = await fetchDependencyVersion(version, source); 341 | if (dependencyVersion) { 342 | version = dependencyVersion; 343 | } else { 344 | version = 'main'; 345 | } 346 | } 347 | 348 | return { 349 | query, 350 | source, 351 | ephemeral, 352 | version, 353 | mention, 354 | }; 355 | } 356 | -------------------------------------------------------------------------------- /src/functions/autocomplete/mdnAutoComplete.ts: -------------------------------------------------------------------------------- 1 | import type { APIApplicationCommandInteractionDataOption } from 'discord-api-types/v10'; 2 | import { InteractionResponseType } from 'discord-api-types/v10'; 3 | import type { Response } from 'polka'; 4 | import type { MdnCommand } from '../../interactions/mdn.js'; 5 | import type { MDNIndexEntry } from '../../types/mdn.js'; 6 | import { AUTOCOMPLETE_MAX_ITEMS, AUTOCOMPLETE_MAX_NAME_LENGTH } from '../../util/constants.js'; 7 | import { transformInteraction } from '../../util/interactionOptions.js'; 8 | import { truncate } from '../../util/truncate.js'; 9 | 10 | type MDNCandidate = { 11 | entry: MDNIndexEntry; 12 | matches: string[]; 13 | }; 14 | 15 | function autoCompleteMap(elements: MDNCandidate[]) { 16 | return elements.map((element) => ({ 17 | name: truncate(element.entry.title, AUTOCOMPLETE_MAX_NAME_LENGTH, ''), 18 | value: element.entry.url, 19 | })); 20 | } 21 | 22 | export function mdnAutoComplete( 23 | res: Response, 24 | options: APIApplicationCommandInteractionDataOption[], 25 | cache: MDNIndexEntry[], 26 | ): Response { 27 | const { query } = transformInteraction(options); 28 | 29 | const parts = query.split(/\.|#/).map((part) => part.toLowerCase()); 30 | const candidates = []; 31 | 32 | for (const entry of cache) { 33 | const lowerTitle = entry.title.toLowerCase(); 34 | const matches = parts.filter((phrase) => lowerTitle.includes(phrase)); 35 | if (matches.length) { 36 | candidates.push({ 37 | entry, 38 | matches, 39 | }); 40 | } 41 | } 42 | 43 | const sortedCandidates = candidates.sort((one, other) => { 44 | if (one.matches.length !== other.matches.length) { 45 | return other.matches.length - one.matches.length; 46 | } 47 | 48 | const aMatches = one.matches.join('').length; 49 | const bMatches = other.matches.join('').length; 50 | return bMatches - aMatches; 51 | }); 52 | 53 | res.setHeader('Content-Type', 'application/json'); 54 | res.write( 55 | JSON.stringify({ 56 | data: { 57 | choices: autoCompleteMap(sortedCandidates).slice(0, AUTOCOMPLETE_MAX_ITEMS - 1), 58 | }, 59 | type: InteractionResponseType.ApplicationCommandAutocompleteResult, 60 | }), 61 | ); 62 | 63 | return res; 64 | } 65 | -------------------------------------------------------------------------------- /src/functions/autocomplete/nodeAutoComplete.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import { stringify } from 'node:querystring'; 3 | import { InteractionResponseType } from 'discord-api-types/v10'; 4 | import type { Response } from 'polka'; 5 | import { fetch } from 'undici'; 6 | import { API_BASE_ORAMA, AUTOCOMPLETE_MAX_ITEMS, AUTOCOMPLETE_MAX_NAME_LENGTH } from '../../util/constants.js'; 7 | import { prepareHeader } from '../../util/respond.js'; 8 | import { truncate } from '../../util/truncate.js'; 9 | 10 | type OramaDocument = { 11 | id: string; 12 | pageSectionTitle: string; 13 | pageTitle: string; 14 | path: string; 15 | siteSection: string; 16 | }; 17 | 18 | type OramaHit = { 19 | document: OramaDocument; 20 | id: string; 21 | score: number; 22 | }; 23 | 24 | type OramaResult = { 25 | count: number; 26 | elapsed: { formatted: string; raw: number }; 27 | facets: { siteSection: { count: number; values: { docs: number } } }; 28 | hits: OramaHit[]; 29 | }; 30 | 31 | function autoCompleteMap(elements: OramaDocument[]) { 32 | return elements.map((element) => { 33 | const cleanSectionTitle = element.pageSectionTitle.replaceAll('`', ''); 34 | const name = truncate(`${element.pageTitle} > ${cleanSectionTitle}`, 90, ''); 35 | if (element.path.length > AUTOCOMPLETE_MAX_NAME_LENGTH) { 36 | return { 37 | name: truncate(`[path too long] ${element.pageTitle} > ${cleanSectionTitle}`, AUTOCOMPLETE_MAX_NAME_LENGTH, ''), 38 | value: element.pageTitle, 39 | }; 40 | } 41 | 42 | return { 43 | name, 44 | // we cannot use the full url with the node api base appended here, since discord only allows string values of length 100 45 | // some of `crypto` results are longer, if prefixed 46 | value: element.path, 47 | }; 48 | }); 49 | } 50 | 51 | export async function nodeAutoComplete(res: Response, query: string): Promise { 52 | const full = `${API_BASE_ORAMA}/indexes/${process.env.ORAMA_CONTAINER}/search?api-key=${process.env.ORAMA_KEY}`; 53 | 54 | const result = (await fetch(full, { 55 | method: 'post', 56 | body: stringify({ 57 | version: '1.3.2', 58 | id: process.env.ORAMA_ID, 59 | // eslint-disable-next-line id-length 60 | q: JSON.stringify({ 61 | term: query, 62 | mode: 'fulltext', 63 | limit: 25, 64 | threshold: 0, 65 | boost: { pageSectionTitle: 4, pageSectionContent: 2.5, pageTitle: 1.5 }, 66 | facets: { siteSection: {} }, 67 | returning: ['path', 'pageSectionTitle', 'pageTitle', 'path', 'siteSection'], 68 | }), 69 | }), 70 | headers: { 71 | 'Content-Type': 'application/x-www-form-urlencoded', 72 | }, 73 | }).then(async (res) => res.json())) as OramaResult; 74 | 75 | prepareHeader(res); 76 | res.write( 77 | JSON.stringify({ 78 | data: { 79 | choices: autoCompleteMap(result.hits?.slice(0, AUTOCOMPLETE_MAX_ITEMS - 1).map((hit) => hit.document) ?? []), 80 | }, 81 | type: InteractionResponseType.ApplicationCommandAutocompleteResult, 82 | }), 83 | ); 84 | 85 | return res; 86 | } 87 | -------------------------------------------------------------------------------- /src/functions/autocomplete/tagAutoComplete.ts: -------------------------------------------------------------------------------- 1 | import type { Collection } from '@discordjs/collection'; 2 | import type { APIApplicationCommandInteractionDataOption } from 'discord-api-types/v10'; 3 | import { InteractionResponseType } from 'discord-api-types/v10'; 4 | import type { Response } from 'polka'; 5 | import type { TagCommand } from '../../interactions/tag.js'; 6 | import { AUTOCOMPLETE_MAX_ITEMS } from '../../util/constants.js'; 7 | import { transformInteraction } from '../../util/interactionOptions.js'; 8 | import type { Tag } from '../tag.js'; 9 | 10 | export function tagAutoComplete( 11 | res: Response, 12 | options: APIApplicationCommandInteractionDataOption[], 13 | tagCache: Collection, 14 | ): Response { 15 | const { query } = transformInteraction(options); 16 | const results: { name: string; value: string }[] = []; 17 | 18 | if (query.length) { 19 | const keywordMatches: { name: string; value: string }[] = []; 20 | const contentMatches: { name: string; value: string }[] = []; 21 | const exactKeywords: { name: string; value: string }[] = []; 22 | const cleanedQuery = query.toLowerCase().replaceAll(/\s+/g, '-'); 23 | 24 | for (const [key, tag] of tagCache.entries()) { 25 | const exactKeyword = 26 | tag.keywords.some((text) => text.toLowerCase() === cleanedQuery) || key.toLowerCase() === cleanedQuery; 27 | const includesKeyword = 28 | tag.keywords.some((text) => text.toLowerCase().includes(cleanedQuery)) || 29 | key.toLowerCase().includes(cleanedQuery); 30 | const isContentMatch = tag.content.toLowerCase().includes(cleanedQuery); 31 | if (exactKeyword) { 32 | exactKeywords.push({ 33 | name: `✅ ${key}`, 34 | value: key, 35 | }); 36 | } else if (includesKeyword) { 37 | keywordMatches.push({ 38 | name: `🔑 ${key}`, 39 | value: key, 40 | }); 41 | } else if (isContentMatch) { 42 | contentMatches.push({ 43 | name: `📄 ${key}`, 44 | value: key, 45 | }); 46 | } 47 | } 48 | 49 | results.push(...exactKeywords, ...keywordMatches, ...contentMatches); 50 | } else { 51 | results.push( 52 | ...tagCache 53 | .filter((tag) => tag.hoisted) 54 | .map((_, key) => ({ 55 | name: `📌 ${key}`, 56 | value: key, 57 | })), 58 | ); 59 | } 60 | 61 | res.setHeader('Content-Type', 'application/json'); 62 | res.write( 63 | JSON.stringify({ 64 | data: { 65 | choices: results.slice(0, AUTOCOMPLETE_MAX_ITEMS - 1), 66 | }, 67 | type: InteractionResponseType.ApplicationCommandAutocompleteResult, 68 | }), 69 | ); 70 | 71 | return res; 72 | } 73 | -------------------------------------------------------------------------------- /src/functions/components/testTagButton.ts: -------------------------------------------------------------------------------- 1 | import { InteractionResponseType, MessageFlags } from 'discord-api-types/v10'; 2 | import type { Response } from 'polka'; 3 | import { prepareHeader } from '../../util/respond.js'; 4 | 5 | export function testTagButton(res: Response) { 6 | prepareHeader(res); 7 | res.write( 8 | JSON.stringify({ 9 | type: InteractionResponseType.UpdateMessage, 10 | data: { 11 | components: [], 12 | embeds: [], 13 | attachments: [], 14 | flags: MessageFlags.SuppressEmbeds, 15 | }, 16 | }), 17 | ); 18 | 19 | return res; 20 | } 21 | -------------------------------------------------------------------------------- /src/functions/docs.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import { bold, codeBlock, hyperlink, inlineCode, strikethrough, underline } from '@discordjs/builders'; 3 | import type { Response } from 'polka'; 4 | import { fetch } from 'undici'; 5 | import { 6 | EMOJI_ID_INTERFACE_DEV, 7 | EMOJI_ID_INTERFACE, 8 | EMOJI_ID_FIELD_DEV, 9 | EMOJI_ID_FIELD, 10 | EMOJI_ID_CLASS_DEV, 11 | EMOJI_ID_CLASS, 12 | EMOJI_ID_METHOD_DEV, 13 | EMOJI_ID_METHOD, 14 | EMOJI_ID_EVENT_DEV, 15 | EMOJI_ID_EVENT, 16 | EMOJI_ID_DJS_DEV, 17 | EMOJI_ID_DJS, 18 | MAX_MESSAGE_LENGTH, 19 | DJS_DOCS_BASE, 20 | EMOJI_ID_ENUM_DEV, 21 | EMOJI_ID_ENUM, 22 | EMOJI_ID_VARIABLE, 23 | EMOJI_ID_VARIABLE_DEV, 24 | } from '../util/constants.js'; 25 | import { logger } from '../util/logger.js'; 26 | import { prepareErrorResponse, prepareResponse } from '../util/respond.js'; 27 | import { truncate } from '../util/truncate.js'; 28 | 29 | /** 30 | * Bucket format 31 | * 32 | * Format: path/pkg/item 33 | * Item: branch.itemName.itemKind.api.json 34 | * Key Example: discord.js/main.actionrow.class.api.json 35 | */ 36 | 37 | type CacheEntry = { 38 | timestamp: number; 39 | value: any; 40 | }; 41 | 42 | const docsCache = new Map(); 43 | 44 | /** 45 | * Fetch a documentation page for a specific query 46 | * 47 | * @param _package - The package name 48 | * @param version - The package version 49 | * @param itemName - The item name 50 | * @param itemKind - The type of the item as per the docs API 51 | * @returns The documentation item 52 | */ 53 | export async function fetchDocItem( 54 | _package: string, 55 | version: string, 56 | itemName: string, 57 | itemKind: string, 58 | ): Promise { 59 | try { 60 | const key = `${_package}/${version}.${itemName}.${itemKind}`; 61 | const hit = docsCache.get(key); 62 | 63 | if (hit) { 64 | return hit.value; 65 | } 66 | 67 | const resourceLink = `${process.env.CF_STORAGE_BASE!}/${key}.api.json`; 68 | logger.debug(`Requesting documentation from CF: ${resourceLink}`); 69 | const value = await fetch(resourceLink).then(async (result) => result.json()); 70 | 71 | docsCache.set(key, { 72 | timestamp: Date.now(), 73 | value, 74 | }); 75 | 76 | return value; 77 | } catch { 78 | return null; 79 | } 80 | } 81 | 82 | /** 83 | * Resolve item kind to the respective Discord app emoji 84 | * 85 | * @param itemKind - The type of item as per the docs API 86 | * @param dev - Whether the item is from the dev branch (main) 87 | * @returns 88 | */ 89 | function itemKindEmoji(itemKind: string, dev = false): [string, string] { 90 | const lowerItemKind = itemKind.toLowerCase(); 91 | switch (itemKind) { 92 | case 'Typedef': 93 | case 'TypeAlias': 94 | case 'Interface': 95 | case 'Model': 96 | return [dev ? EMOJI_ID_INTERFACE_DEV : EMOJI_ID_INTERFACE, lowerItemKind]; 97 | case 'PropertySignature': 98 | case 'Property': 99 | case 'IndexSignature': 100 | return [dev ? EMOJI_ID_FIELD_DEV : EMOJI_ID_FIELD, lowerItemKind]; 101 | case 'Class': 102 | case 'Constructor': 103 | case 'ConstructSignature': 104 | return [dev ? EMOJI_ID_CLASS_DEV : EMOJI_ID_CLASS, lowerItemKind]; 105 | case 'Method': 106 | case 'MethodSignature': 107 | case 'Function': 108 | case 'CallSignature': 109 | return [dev ? EMOJI_ID_METHOD_DEV : EMOJI_ID_METHOD, lowerItemKind]; 110 | case 'Event': 111 | return [dev ? EMOJI_ID_EVENT_DEV : EMOJI_ID_EVENT, lowerItemKind]; 112 | case 'Enum': 113 | case 'EnumMember': 114 | return [dev ? EMOJI_ID_ENUM_DEV : EMOJI_ID_ENUM, lowerItemKind]; 115 | case 'Variable': 116 | return [dev ? EMOJI_ID_VARIABLE_DEV : EMOJI_ID_VARIABLE, lowerItemKind]; 117 | default: 118 | return [dev ? EMOJI_ID_DJS_DEV : EMOJI_ID_DJS, lowerItemKind]; 119 | } 120 | } 121 | 122 | /** 123 | * Build a discord.js documentation link 124 | * 125 | * @param item - The item to generate the link for 126 | * @param _package - The package name 127 | * @param version - The package version 128 | * @param attribute - The attribute to link to, if any 129 | * @returns The formatted link 130 | */ 131 | function docsLink(item: any, _package: string, version: string, attribute?: string) { 132 | return `${DJS_DOCS_BASE}/packages/${_package}/${version}/${item.displayName}:${item.kind}${ 133 | attribute ? `#${attribute}` : '' 134 | }`; 135 | } 136 | 137 | /** 138 | * Enriches item members of type "method" with a dynamically generated displayName property 139 | * 140 | * @param potential - The item to check and enrich 141 | * @param member - The member to access 142 | * @param topLevelDisplayName - The display name of the top level parent 143 | * @returns The enriched item 144 | */ 145 | function enrichItem(potential: any, member: any, topLevelDisplayName: string): any | null { 146 | const isMethod = potential.kind === 'Method'; 147 | if (potential.displayName?.toLowerCase() === member.toLowerCase()) { 148 | return { 149 | ...potential, 150 | displayName: `${topLevelDisplayName}#${potential.displayName}${isMethod ? '()' : ''}`, 151 | }; 152 | } 153 | 154 | return null; 155 | } 156 | 157 | /** 158 | * Resolve an items specific member, if required. 159 | * 160 | * @param item - The base item to check 161 | * @param member - The name of the member to access 162 | * @returns The relevant item 163 | */ 164 | function effectiveItem(item: any, member?: string) { 165 | if (!member) { 166 | return item; 167 | } 168 | 169 | const iterable = Array.isArray(item.members); 170 | if (Array.isArray(item.members)) { 171 | for (const potential of item.members) { 172 | const hit = enrichItem(potential, member, item.displayName); 173 | if (hit) { 174 | return hit; 175 | } 176 | } 177 | } else { 178 | for (const category of Object.values(item.members)) { 179 | for (const potential of category as any) { 180 | const hit = enrichItem(potential, member, item.displayName); 181 | if (hit) { 182 | return hit; 183 | } 184 | } 185 | } 186 | } 187 | 188 | return item; 189 | } 190 | 191 | /** 192 | * Format documentation blocks to a summary string 193 | * 194 | * @param blocks - The documentation blocks to format 195 | * @param _package - The package name of the package the blocks belong to 196 | * @param version - The version of the package the blocks belong to 197 | * @returns The formatted summary string 198 | */ 199 | function formatSummary(blocks: any[], _package: string, version: string) { 200 | return blocks 201 | .map((block) => { 202 | if (block.kind === 'LinkTag' && block.uri) { 203 | const isFullLink = block.uri.startsWith('http'); 204 | const link = isFullLink ? block.uri : `${DJS_DOCS_BASE}/packages/${_package}/${version}/${block.uri}`; 205 | return hyperlink(block.members ? `${block.text}${block.members}` : block.text, link); 206 | } 207 | 208 | return block.text; 209 | }) 210 | .join(''); 211 | } 212 | 213 | /** 214 | * Format documentation blocks to a code example string 215 | * 216 | * @param blocks - The documentation blocks to format 217 | * @returns The formatted code example string 218 | */ 219 | function formatExample(blocks?: any[]) { 220 | const comments: string[] = []; 221 | 222 | if (!blocks) { 223 | return; 224 | } 225 | 226 | for (const block of blocks) { 227 | if (block.kind === 'PlainText' && block.text.length) { 228 | comments.push(`// ${block.text}`); 229 | continue; 230 | } 231 | 232 | if (block.kind === 'FencedCode') { 233 | return codeBlock(block.language, `${comments.join('\n')}\n${block.text}`); 234 | } 235 | } 236 | } 237 | 238 | /** 239 | * Format the provided docs source item to a source link, if available 240 | * 241 | * @param item - The docs source item to format 242 | * @param _package - The package to use 243 | * @param version - The version to use 244 | * @returns The formatted link, if available, otherwise the provided versionstring 245 | */ 246 | function formatSourceURL(item: any, _package: string, version: string) { 247 | const sourceUrl = item.sourceURL; 248 | const versionString = inlineCode(`${_package}@${version}`); 249 | 250 | if (!item.sourceURL?.startsWith('http')) { 251 | return versionString; 252 | } 253 | 254 | const link = `${sourceUrl}${item.sourceLine ? `#L${item.sourceLine}` : ''}`; 255 | return hyperlink(versionString, link, 'source code'); 256 | } 257 | 258 | /** 259 | * Format a documentation item to a string 260 | * 261 | * @param _item - The docs item to format 262 | * @param _package - The package name of the packge the item belongs to 263 | * @param version - The version of the package the item belongs to 264 | * @param member - The specific item member to access, if any 265 | * @returns The formatted documentation string for the provided item 266 | */ 267 | function formatItem(_item: any, _package: string, version: string, member?: string) { 268 | const itemLink = docsLink(_item, _package, version, member); 269 | const item = effectiveItem(_item, member); 270 | 271 | const [emojiId, emojiName] = itemKindEmoji(item.kind, version === 'main'); 272 | 273 | const parts: string[] = []; 274 | 275 | if (item.kind === 'Event') { 276 | parts.push(bold('(event)')); 277 | } 278 | 279 | if (item.isStatic) { 280 | parts.push(bold('(static)')); 281 | } 282 | 283 | parts.push(underline(bold(hyperlink(item.displayName, itemLink)))); 284 | 285 | const head = `<:${emojiName}:${emojiId}>`; 286 | const tail = formatSourceURL(item, _package, version); 287 | const middlePart = item.isDeprecated ? strikethrough(parts.join(' ')) : parts.join(' '); 288 | 289 | const lines: string[] = [[head, middlePart, tail].join(' ')]; 290 | 291 | const summary = item.summary?.summarySection; 292 | const defaultValueBlock = item.summary?.defaultValueBlock; 293 | const deprecationNote = item.summary?.deprecatedBlock; 294 | const example = formatExample(item.summary?.exampleBlocks); 295 | const defaultValue = defaultValueBlock ? formatSummary(defaultValueBlock, _package, version) : null; 296 | 297 | if (deprecationNote?.length) { 298 | lines.push(`${bold('[DEPRECATED]')} ${formatSummary(deprecationNote, _package, version)}`); 299 | } else { 300 | if (summary?.length) { 301 | lines.push(formatSummary(summary, _package, version)); 302 | } 303 | 304 | if (example) { 305 | lines.push(example); 306 | } 307 | } 308 | 309 | if (defaultValue?.length) { 310 | lines.push(`Default value: ${inlineCode(defaultValue)}`); 311 | } 312 | 313 | return lines.join('\n'); 314 | } 315 | 316 | export async function djsDocs(res: Response, version: string, query: string, user?: string, ephemeral?: boolean) { 317 | try { 318 | if (!query) { 319 | prepareErrorResponse(res, 'Cannot find any hits for the provided query - consider using auto complete.'); 320 | return res.end(); 321 | } 322 | 323 | const [_package, itemName, itemKind, member] = query.split('|'); 324 | const item = await fetchDocItem(_package, version, itemName, itemKind.toLowerCase()); 325 | if (!item) { 326 | prepareErrorResponse(res, `Could not fetch doc entry for query ${inlineCode(query)}.`); 327 | return res.end(); 328 | } 329 | 330 | prepareResponse(res, truncate(formatItem(item, _package, version, member), MAX_MESSAGE_LENGTH), { 331 | ephemeral, 332 | suggestion: user ? { userId: user, kind: 'documentation' } : undefined, 333 | }); 334 | return res.end(); 335 | } catch (_error) { 336 | const error = _error as Error; 337 | logger.error(error, error.message); 338 | prepareErrorResponse(res, 'Something went wrong while executing the command.'); 339 | return res.end(); 340 | } 341 | } 342 | -------------------------------------------------------------------------------- /src/functions/mdn.ts: -------------------------------------------------------------------------------- 1 | import { bold, hideLinkEmbed, hyperlink, inlineCode, italic, underscore, userMention } from '@discordjs/builders'; 2 | import type { Response } from 'polka'; 3 | import { fetch } from 'undici'; 4 | import { API_BASE_MDN, EMOJI_ID_MDN } from '../util/constants.js'; 5 | import { logger } from '../util/logger.js'; 6 | import { prepareErrorResponse, prepareResponse } from '../util/respond.js'; 7 | 8 | const cache = new Map(); 9 | 10 | function escape(text: string) { 11 | return text.replaceAll('||', '|\u200B|').replaceAll('*', '\\*'); 12 | } 13 | 14 | export async function mdnSearch(res: Response, query: string, user?: string, ephemeral?: boolean): Promise { 15 | const trimmedQuery = query.trim(); 16 | try { 17 | const qString = `${API_BASE_MDN}/${trimmedQuery}/index.json`; 18 | // eslint-disable-next-line sonarjs/no-empty-collection 19 | let hit = cache.get(qString); 20 | if (!hit) { 21 | try { 22 | const result = (await fetch(qString).then(async (response) => response.json())) as APIResult; 23 | hit = result.doc; 24 | } catch { 25 | prepareErrorResponse(res, 'Invalid result. Make sure to select an entry from the autocomplete.'); 26 | return res; 27 | } 28 | } 29 | 30 | const url = API_BASE_MDN + hit.mdn_url; 31 | 32 | const linkReplaceRegex = /\[(.+?)]\((.+?)\)/g; 33 | const boldCodeBlockRegex = /`\*\*(.*)\*\*`/g; 34 | const intro = escape(hit.summary) 35 | .replaceAll(/\s+/g, ' ') 36 | .replaceAll(linkReplaceRegex, hyperlink('$1', hideLinkEmbed(`${API_BASE_MDN}$2`))) 37 | .replaceAll(boldCodeBlockRegex, bold(inlineCode('$1'))); 38 | 39 | const parts = [ 40 | `<:mdn:${EMOJI_ID_MDN}> ${underscore(bold(hyperlink(escape(hit.title), hideLinkEmbed(url))))}`, 41 | intro, 42 | ]; 43 | 44 | prepareResponse(res, parts.join('\n'), { 45 | ephemeral, 46 | suggestion: user ? { userId: user, kind: 'documentation' } : undefined, 47 | }); 48 | 49 | return res; 50 | } catch (error) { 51 | logger.error(error as Error); 52 | prepareErrorResponse(res, `Something went wrong.`); 53 | return res; 54 | } 55 | } 56 | 57 | type APIResult = { 58 | doc: Document; 59 | }; 60 | 61 | type Document = { 62 | archived: boolean; 63 | highlight: Highlight; 64 | locale: string; 65 | mdn_url: string; 66 | popularity: number; 67 | score: number; 68 | slug: string; 69 | summary: string; 70 | title: string; 71 | }; 72 | 73 | type Highlight = { 74 | body: string[]; 75 | title: string[]; 76 | }; 77 | -------------------------------------------------------------------------------- /src/functions/modals/testTagModalSubmit.ts: -------------------------------------------------------------------------------- 1 | import { Buffer } from 'node:buffer'; 2 | import process from 'node:process'; 3 | import type { RawFile } from '@discordjs/rest'; 4 | import { REST } from '@discordjs/rest'; 5 | import * as TOML from '@ltd/j-toml'; 6 | import type { APIButtonComponent, APIModalSubmitInteraction } from 'discord-api-types/v10'; 7 | import { ButtonStyle, ComponentType, InteractionResponseType, MessageFlags, Routes } from 'discord-api-types/v10'; 8 | import type { Response } from 'polka'; 9 | import { 10 | EMOJI_ID_NO_TEST, 11 | VALIDATION_FAIL_COLOR, 12 | VALIDATION_WARNING_COLOR, 13 | VALIDATION_SUCCESS_COLOR, 14 | } from '../../util/constants.js'; 15 | import { prepareErrorResponse, prepareHeader } from '../../util/respond.js'; 16 | import { validateTags } from '../../workflowFunctions/validateTags.js'; 17 | 18 | function parseTagShape(data: string) { 19 | const toml = TOML.parse(data, 1, '\n'); 20 | const tag: [string, any | null] = Object.entries(toml)[0]; 21 | const name = tag[0]; 22 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 23 | const tagBody = tag[1]; 24 | 25 | if (!name) { 26 | throw new Error('Unexpected tag shape. Needs name key (string).'); 27 | } 28 | 29 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 30 | if (!tagBody?.keywords?.length || !Array.isArray(tagBody.keywords)) { 31 | throw new Error('Unexpected tag shape. Needs keywords (string[]).'); 32 | } 33 | 34 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 35 | if (!tagBody?.content?.length || typeof tagBody.content !== 'string') { 36 | throw new Error('Unexpected tag shape. Needs content (string).'); 37 | } 38 | 39 | return { 40 | name, 41 | body: tagBody as { content: string; keywords: string[] }, 42 | }; 43 | } 44 | 45 | export async function testTagModalSubmit(res: Response, message: APIModalSubmitInteraction) { 46 | const tagData = message.data.components?.[0].components[0]; 47 | if (!tagData) { 48 | prepareErrorResponse( 49 | res, 50 | 'Tag format looks different than expected. Make sure to include the tag name and keywords and review our [guidelines and examples](https://github.com/discordjs/discord-utils-bot).', 51 | ); 52 | return res; 53 | } 54 | 55 | try { 56 | const parsedTag = parseTagShape(tagData.value); 57 | const result = await validateTags(false, tagData.value); 58 | 59 | const hasErrors = result.errors.length; 60 | const hasWarnings = result.warnings.length; 61 | const attachments: RawFile[] = []; 62 | const buttons: APIButtonComponent[] = [ 63 | { 64 | type: ComponentType.Button, 65 | style: ButtonStyle.Secondary, 66 | emoji: { 67 | id: EMOJI_ID_NO_TEST, 68 | }, 69 | custom_id: 'testtag-clear', 70 | }, 71 | ]; 72 | 73 | if (hasErrors) { 74 | attachments.push({ 75 | name: 'errors.ansi', 76 | data: Buffer.from(result.errors.join('\n')), 77 | }); 78 | } 79 | 80 | if (hasWarnings) { 81 | attachments.push({ 82 | name: 'warnings.ansi', 83 | data: Buffer.from(result.warnings.join('\n')), 84 | }); 85 | } 86 | 87 | if (!hasWarnings && !hasErrors) { 88 | buttons.push({ 89 | type: ComponentType.Button, 90 | style: ButtonStyle.Link, 91 | label: 'Create a PR!', 92 | url: 'https://github.com/discordjs/discord-utils-bot/compare', 93 | }); 94 | } 95 | 96 | const rest = new REST(); 97 | rest.setToken(process.env.DISCORD_TOKEN!); 98 | await rest.post(Routes.interactionCallback(message.id, message.token), { 99 | body: { 100 | type: InteractionResponseType.ChannelMessageWithSource, 101 | data: { 102 | allowed_mentions: { parse: [] }, 103 | content: parsedTag.body.content, 104 | flags: message.data.custom_id === 'testtag-hide' ? MessageFlags.Ephemeral : 0, 105 | embeds: [ 106 | { 107 | color: hasErrors 108 | ? VALIDATION_FAIL_COLOR 109 | : hasWarnings 110 | ? VALIDATION_WARNING_COLOR 111 | : VALIDATION_SUCCESS_COLOR, 112 | description: [ 113 | `**Name:** \`${parsedTag.name}\``, 114 | `**Keywords:** ${parsedTag.body.keywords.map((key) => `\`${key}\``).join(', ')}`, 115 | `**Validation**: ${ 116 | hasErrors ? 'invalid' : hasWarnings ? `valid (${result.warnings.length} warnings)` : 'valid' 117 | }`, 118 | ].join('\n'), 119 | }, 120 | ], 121 | components: buttons.length 122 | ? [ 123 | { 124 | type: ComponentType.ActionRow, 125 | components: buttons, 126 | }, 127 | ] 128 | : [], 129 | }, 130 | }, 131 | files: attachments, 132 | }); 133 | } catch (_error) { 134 | const error = _error as Error; 135 | 136 | prepareHeader(res); 137 | prepareErrorResponse(res, error.message); 138 | return res; 139 | } 140 | 141 | prepareHeader(res); 142 | res.write(JSON.stringify({})); 143 | return res; 144 | } 145 | -------------------------------------------------------------------------------- /src/functions/node.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-call */ 2 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */ 3 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 4 | 5 | import { URL } from 'node:url'; 6 | import { bold, hideLinkEmbed, hyperlink, inlineCode, italic, underscore, userMention } from '@discordjs/builders'; 7 | import * as cheerio from 'cheerio'; 8 | import type { Response } from 'polka'; 9 | import TurndownService from 'turndown'; 10 | import { fetch } from 'undici'; 11 | import type { NodeDocs } from '../types/NodeDocs.js'; 12 | import { API_BASE_NODE, AUTOCOMPLETE_MAX_NAME_LENGTH, EMOJI_ID_NODE } from '../util/constants.js'; 13 | import { logger } from '../util/logger.js'; 14 | import { prepareErrorResponse, prepareResponse } from '../util/respond.js'; 15 | import { truncate } from '../util/truncate.js'; 16 | import { urlOption } from '../util/url.js'; 17 | 18 | const td = new TurndownService({ codeBlockStyle: 'fenced' }); 19 | 20 | type QueryType = 'class' | 'classMethod' | 'event' | 'global' | 'method' | 'misc' | 'module'; 21 | 22 | function urlReplacer(_: string, label: string, link: string, version: string) { 23 | const resolvedLink = link.startsWith('http') ? link : `${API_BASE_NODE}/docs/${version}/api/${link}`; 24 | return hyperlink(label, hideLinkEmbed(resolvedLink)); 25 | } 26 | 27 | function findRec(object: any, name: string, type: QueryType, module?: string, source?: string): any { 28 | const lowerName = name.toLowerCase(); 29 | const resolvedModule = object?.type === 'module' ? object?.name.toLowerCase() : module ?? undefined; 30 | object._source = source; 31 | if (object?.name?.toLowerCase() === lowerName && object?.type === type) { 32 | object.module = resolvedModule; 33 | return object; 34 | } 35 | 36 | for (const prop of Object.keys(object)) { 37 | if (Array.isArray(object[prop])) { 38 | for (const entry of object[prop]) { 39 | const res = findRec(entry, name, type, resolvedModule, object.source ?? object._source); 40 | if (res) { 41 | object.module = resolvedModule; 42 | return res; 43 | } 44 | } 45 | } 46 | } 47 | } 48 | 49 | function findResult(data: any, query: string) { 50 | for (const category of ['class', 'classMethod', 'method', 'event', 'module', 'global', 'misc'] as QueryType[]) { 51 | const res = findRec(data, query, category); 52 | if (res) { 53 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return 54 | return res; 55 | } 56 | } 57 | } 58 | 59 | function formatAnchorText(anchorTextRaw: string) { 60 | return anchorTextRaw.replaceAll(/\W/g, (match) => (match === ' ' ? '-' : '')).toLowerCase(); 61 | } 62 | 63 | function parsePageFromSource(source: string): string | null { 64 | const reg = /.+\/api\/(.+)\..*/g; 65 | const match = reg.exec(source); 66 | return match?.[1] ?? null; 67 | } 68 | 69 | function docsUrl(version: string, source: string, anchorTextRaw: string) { 70 | return `${API_BASE_NODE}/docs/${version}/api/${parsePageFromSource(source)}.html#${formatAnchorText(anchorTextRaw)}`; 71 | } 72 | 73 | const jsonCache: Map = new Map(); 74 | const docsCache: Map = new Map(); 75 | 76 | export async function nodeAutoCompleteResolve(res: Response, query: string, user?: string, ephemeral?: boolean) { 77 | const url = urlOption(`${API_BASE_NODE}/${query}`); 78 | 79 | if (!url || !query.startsWith('docs')) { 80 | return nodeSearch(res, query, undefined, user, ephemeral); 81 | } 82 | 83 | const key = `${url.origin}${url.pathname}`; 84 | let html = docsCache.get(key); 85 | 86 | if (!html) { 87 | const data = await fetch(url.toString()).then(async (response) => response.text()); 88 | docsCache.set(key, data); 89 | html = data; 90 | } 91 | 92 | const $ = cheerio.load(html); 93 | 94 | const possible = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']; 95 | 96 | const headingBaseSelectorParts = possible.map((prefix) => `${prefix}:has(${url.hash})`); 97 | const heaidngSelector = headingBaseSelectorParts.join(', '); 98 | const headingCodeSelector = headingBaseSelectorParts.map((part) => `${part} > code`).join(', '); 99 | const paragraphSelector = headingBaseSelectorParts.join(', '); 100 | 101 | const heading = $(heaidngSelector).text().replaceAll('#', ''); 102 | const headingCode = $(headingCodeSelector).text(); 103 | const paragraph = $(paragraphSelector).nextUntil('h4', 'p'); 104 | 105 | const text = paragraph.text().split('\n').join(' '); 106 | const sentence = text.split(/[!.?](\s|$)/)?.[0]; 107 | const effectiveSentence = (sentence ?? truncate(text, AUTOCOMPLETE_MAX_NAME_LENGTH, '')).trim(); 108 | 109 | const contentParts = [ 110 | `<:node:${EMOJI_ID_NODE}> ${hyperlink(inlineCode(headingCode.length ? headingCode : heading), url.toString())}`, 111 | ]; 112 | 113 | if (effectiveSentence.length) { 114 | contentParts.push(`${effectiveSentence}.`); 115 | } 116 | 117 | prepareResponse(res, contentParts.join('\n'), { 118 | ephemeral, 119 | suggestion: user ? { userId: user, kind: 'documentation' } : undefined, 120 | }); 121 | 122 | return res; 123 | } 124 | 125 | export async function nodeSearch( 126 | res: Response, 127 | query: string, 128 | version = 'latest-v20.x', 129 | user?: string, 130 | ephemeral?: boolean, 131 | ): Promise { 132 | const trimmedQuery = query.trim(); 133 | try { 134 | const url = `${API_BASE_NODE}/dist/${version}/docs/api/all.json`; 135 | let allNodeData = jsonCache.get(url); 136 | 137 | if (!allNodeData) { 138 | const data = (await fetch(url).then(async (response) => response.json())) as NodeDocs; 139 | jsonCache.set(url, data); 140 | allNodeData = data; 141 | } 142 | 143 | const queryParts = trimmedQuery.split(/[\s#.]/); 144 | const altQuery = queryParts[queryParts.length - 1]; 145 | const result = findResult(allNodeData, trimmedQuery) ?? findResult(allNodeData, altQuery); 146 | 147 | if (!result) { 148 | prepareErrorResponse(res, `No result found for query ${inlineCode(trimmedQuery)}.`); 149 | return res; 150 | } 151 | 152 | const parts = [ 153 | `<:node:${EMOJI_ID_NODE}> ${hyperlink( 154 | result.textRaw, 155 | docsUrl(version, result.source ?? result._source, result.textRaw), 156 | )}`, 157 | ]; 158 | 159 | const intro = td.turndown(result.desc ?? '').split('\n\n')[0]; 160 | const linkReplaceRegex = /\[(.+?)]\((.+?)\)/g; 161 | const boldCodeBlockRegex = /`\*\*(.*)\*\*`/g; 162 | 163 | parts.push( 164 | intro 165 | .replaceAll(linkReplaceRegex, (_, label, link) => urlReplacer(_, label, link, version)) 166 | .replaceAll(boldCodeBlockRegex, bold(inlineCode('$1'))), 167 | ); 168 | 169 | prepareResponse(res, parts.join('\n'), { 170 | ephemeral, 171 | suggestion: user ? { userId: user, kind: 'documentation' } : undefined, 172 | }); 173 | 174 | return res; 175 | } catch (error) { 176 | logger.error(error as Error); 177 | prepareErrorResponse(res, `Something went wrong.`); 178 | return res; 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/functions/tag.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from 'node:fs/promises'; 2 | import { dirname, join } from 'node:path'; 3 | import { fileURLToPath } from 'node:url'; 4 | import type { Collection } from '@discordjs/collection'; 5 | import * as TOML from '@ltd/j-toml'; 6 | import type { Response } from 'polka'; 7 | import readdirp from 'readdirp'; 8 | import { fetch } from 'undici'; 9 | import { REMOTE_TAG_URL, PREFIX_SUCCESS } from '../util/constants.js'; 10 | import { logger } from '../util/logger.js'; 11 | import { prepareResponse, prepareErrorResponse } from '../util/respond.js'; 12 | 13 | export type Tag = { 14 | content: string; 15 | hoisted: boolean; 16 | keywords: string[]; 17 | }; 18 | 19 | export type TagSimilarityEntry = { 20 | lev: number; 21 | name: string; 22 | word: string; 23 | }; 24 | 25 | export async function loadTags(tagCache: Collection, remote = false) { 26 | const tagFileNames = readdirp(join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'tags'), { 27 | fileFilter: '*.toml', 28 | }); 29 | 30 | const parts: string[] = []; 31 | 32 | for await (const dir of tagFileNames) { 33 | const file = remote 34 | ? await fetch(`${REMOTE_TAG_URL}/${dir.basename}`) 35 | .then(async (response) => response.text()) 36 | .catch((_error) => { 37 | const error = _error as Error; 38 | logger.error(error, error.message); 39 | return `# ${error.message}`; 40 | }) 41 | : await readFile(dir.fullPath, { encoding: 'utf8' }); 42 | parts.push(file); 43 | } 44 | 45 | const data = TOML.parse(parts.join('\n\n'), 1, '\n'); 46 | for (const [key, value] of Object.entries(data)) { 47 | tagCache.set(key, value as unknown as Tag); 48 | } 49 | } 50 | 51 | export function findTag(tagCache: Collection, query: string): string | null { 52 | const cleanQuery = query.replaceAll(/\s+/g, '-'); 53 | const tag = tagCache.get(cleanQuery) ?? tagCache.find((tag) => tag.keywords.includes(cleanQuery)); 54 | if (!tag) return null; 55 | return tag.content; 56 | } 57 | 58 | export async function reloadTags(res: Response, tagCache: Collection, remote = true) { 59 | const prev = tagCache.size; 60 | tagCache.clear(); 61 | try { 62 | await loadTags(tagCache, remote); 63 | prepareResponse( 64 | res, 65 | [ 66 | `${PREFIX_SUCCESS} **Tags have fully reloaded ${remote ? '(remote)' : '(local)'}!**`, 67 | `Tag cache size has changed from ${prev} to ${tagCache.size}.`, 68 | ].join('\n'), 69 | { ephemeral: true }, 70 | ); 71 | } catch (error) { 72 | logger.error(error as Error); 73 | prepareErrorResponse( 74 | res, 75 | `Something went wrong while loading tags ${remote ? '(remote)' : '(local)'}\n\`${(error as Error).message}\``, 76 | ); 77 | } 78 | 79 | return res; 80 | } 81 | 82 | export function showTag( 83 | res: Response, 84 | query: string, 85 | tagCache: Collection, 86 | user?: string, 87 | ephemeral?: boolean, 88 | ): Response { 89 | const trimmedQuery = query.trim().toLowerCase(); 90 | const content = findTag(tagCache, trimmedQuery); 91 | 92 | if (content) { 93 | prepareResponse(res, content, { ephemeral, suggestion: user ? { userId: user, kind: 'tag' } : undefined }); 94 | } else { 95 | prepareErrorResponse(res, `Could not find a tag with name or alias similar to \`${trimmedQuery}\`.`); 96 | } 97 | 98 | return res; 99 | } 100 | -------------------------------------------------------------------------------- /src/functions/testtag.ts: -------------------------------------------------------------------------------- 1 | import { ComponentType, InteractionResponseType } from 'discord-api-types/v10'; 2 | import type { Response } from 'polka'; 3 | import { prepareHeader } from '../util/respond.js'; 4 | 5 | const PLACEHOLDER = [ 6 | '[tagname]', 7 | 'keywords = ["keyword", "another-keyword"]', 8 | 'content = """', 9 | 'Put your tag content here!', 10 | '"""', 11 | ].join('\n'); 12 | 13 | export function testTag(res: Response, hide: boolean): Response { 14 | prepareHeader(res); 15 | res.write( 16 | JSON.stringify({ 17 | type: InteractionResponseType.Modal, 18 | data: { 19 | custom_id: `testtag-${hide ? 'hide' : 'show'}`, 20 | title: 'Enter the tag data to test', 21 | components: [ 22 | { 23 | type: ComponentType.ActionRow, 24 | components: [ 25 | { 26 | custom_id: 'testtaginput', 27 | label: 'Tag data', 28 | style: 2, 29 | min_length: 1, 30 | max_length: 4_000, 31 | placeholder: PLACEHOLDER, 32 | value: PLACEHOLDER, 33 | required: true, 34 | type: ComponentType.TextInput, 35 | }, 36 | ], 37 | }, 38 | ], 39 | }, 40 | }), 41 | ); 42 | return res; 43 | } 44 | -------------------------------------------------------------------------------- /src/handling/handleApplicationCommand.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import type { Collection } from '@discordjs/collection'; 3 | import type { APIApplicationCommandInteraction } from 'discord-api-types/v10'; 4 | import { ApplicationCommandType } from 'discord-api-types/v10'; 5 | import type { Response } from 'polka'; 6 | import { deploy } from '../deployFunctions/deploy.js'; 7 | import { algoliaResponse } from '../functions/algoliaResponse.js'; 8 | import { resolveOptionsToDocsAutoComplete } from '../functions/autocomplete/docsAutoComplete.js'; 9 | import { djsDocs } from '../functions/docs.js'; 10 | import { mdnSearch } from '../functions/mdn.js'; 11 | import { nodeAutoCompleteResolve } from '../functions/node.js'; 12 | import type { Tag } from '../functions/tag.js'; 13 | import { showTag, reloadTags } from '../functions/tag.js'; 14 | import { testTag } from '../functions/testtag.js'; 15 | import { DiscordDocsCommand } from '../interactions/discorddocs.js'; 16 | import { DTypesCommand } from '../interactions/discordtypes.js'; 17 | import { buildDocsCommand, DocsCommand } from '../interactions/docs.js'; 18 | import { GuideCommand } from '../interactions/guide.js'; 19 | import { MdnCommand } from '../interactions/mdn.js'; 20 | import { NodeCommand } from '../interactions/node.js'; 21 | import { TagCommand } from '../interactions/tag.js'; 22 | import type { TagReloadCommand } from '../interactions/tagreload.js'; 23 | import { TestTagCommand } from '../interactions/testtag.js'; 24 | import type { ArgumentsOf } from '../util/argumentsOf.js'; 25 | import { EMOJI_ID_CLYDE_BLURPLE, EMOJI_ID_DTYPES, EMOJI_ID_GUIDE } from '../util/constants.js'; 26 | import { reloadDjsVersions } from '../util/djsdocs.js'; 27 | import { transformInteraction } from '../util/interactionOptions.js'; 28 | import { prepareErrorResponse, prepareResponse } from '../util/respond.js'; 29 | 30 | const staticGlobalCommands = [ 31 | DiscordDocsCommand, 32 | GuideCommand, 33 | MdnCommand, 34 | NodeCommand, 35 | TagCommand, 36 | TestTagCommand, 37 | DTypesCommand, 38 | ]; 39 | 40 | type CommandName = 41 | | 'discorddocs' 42 | | 'docs' 43 | | 'dtypes' 44 | | 'guide' 45 | | 'mdn' 46 | | 'node' 47 | | 'reloadversions' 48 | | 'tag' 49 | | 'tagreload' 50 | | 'testtag'; 51 | 52 | export async function handleApplicationCommand( 53 | res: Response, 54 | message: APIApplicationCommandInteraction, 55 | tagCache: Collection, 56 | ) { 57 | const data = message.data; 58 | if (data.type === ApplicationCommandType.ChatInput) { 59 | const options = data.options ?? []; 60 | const name = data.name as CommandName; 61 | const args = transformInteraction(options); 62 | 63 | switch (name) { 64 | case 'docs': { 65 | const resolved = await resolveOptionsToDocsAutoComplete(options); 66 | if (!resolved) { 67 | prepareErrorResponse(res, `Payload looks different than expected`); 68 | break; 69 | } 70 | 71 | const { query, version, ephemeral, mention } = resolved; 72 | await djsDocs(res, version, query, mention, ephemeral); 73 | break; 74 | } 75 | 76 | case 'discorddocs': { 77 | const castArgs = args as ArgumentsOf; 78 | await algoliaResponse( 79 | res, 80 | process.env.DDOCS_ALGOLIA_APP!, 81 | process.env.DDOCS_ALGOLIA_KEY!, 82 | 'discord', 83 | castArgs.query, 84 | EMOJI_ID_CLYDE_BLURPLE, 85 | 'discord', 86 | castArgs.mention, 87 | castArgs.hide, 88 | ); 89 | break; 90 | } 91 | 92 | case 'dtypes': { 93 | const castArgs = args as ArgumentsOf; 94 | await algoliaResponse( 95 | res, 96 | process.env.DTYPES_ALGOLIA_APP!, 97 | process.env.DTYPES_ALGOLIA_KEY!, 98 | 'discord-api-types', 99 | castArgs.query, 100 | EMOJI_ID_DTYPES, 101 | 'dtypes', 102 | castArgs.mention, 103 | castArgs.hide, 104 | ); 105 | 106 | break; 107 | } 108 | 109 | case 'guide': { 110 | const castArgs = args as ArgumentsOf; 111 | await algoliaResponse( 112 | res, 113 | process.env.DJS_GUIDE_ALGOLIA_APP!, 114 | process.env.DJS_GUIDE_ALGOLIA_KEY!, 115 | 'discordjs', 116 | castArgs.query, 117 | EMOJI_ID_GUIDE, 118 | 'guide', 119 | castArgs.mention, 120 | castArgs.hide, 121 | 'guide', 122 | ); 123 | break; 124 | } 125 | 126 | case 'mdn': { 127 | const castArgs = args as ArgumentsOf; 128 | await mdnSearch(res, castArgs.query, castArgs.mention, castArgs.hide); 129 | break; 130 | } 131 | 132 | case 'node': { 133 | const castArgs = args as ArgumentsOf; 134 | await nodeAutoCompleteResolve(res, castArgs.query, castArgs.mention, castArgs.hide); 135 | break; 136 | } 137 | 138 | case 'tag': { 139 | const castArgs = args as ArgumentsOf; 140 | showTag(res, castArgs.query, tagCache, castArgs.mention, castArgs.hide); 141 | break; 142 | } 143 | 144 | case 'testtag': { 145 | const castArgs = args as ArgumentsOf; 146 | testTag(res, castArgs.hide ?? true); 147 | break; 148 | } 149 | 150 | case 'tagreload': { 151 | const castArgs = args as ArgumentsOf; 152 | await reloadTags(res, tagCache, castArgs.remote ?? false); 153 | break; 154 | } 155 | 156 | case 'reloadversions': { 157 | const versions = await reloadDjsVersions(); 158 | const updatedDocsCommand = await buildDocsCommand(versions); 159 | await deploy([...staticGlobalCommands, updatedDocsCommand]); 160 | 161 | prepareResponse(res, `Reloaded versions for all supported packages (dependency of discord.js).`, { 162 | ephemeral: true, 163 | }); 164 | break; 165 | } 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/handling/handleApplicationCommandAutocomplete.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import type { Collection } from '@discordjs/collection'; 3 | import type { APIApplicationCommandAutocompleteInteraction } from 'discord-api-types/v10'; 4 | import type { Response } from 'polka'; 5 | import { algoliaAutoComplete } from '../functions/autocomplete/algoliaAutoComplete.js'; 6 | import { djsAutoComplete } from '../functions/autocomplete/docsAutoComplete.js'; 7 | import { mdnAutoComplete } from '../functions/autocomplete/mdnAutoComplete.js'; 8 | import { nodeAutoComplete } from '../functions/autocomplete/nodeAutoComplete.js'; 9 | import { tagAutoComplete } from '../functions/autocomplete/tagAutoComplete.js'; 10 | import type { Tag } from '../functions/tag.js'; 11 | import type { DTypesCommand } from '../interactions/discordtypes.js'; 12 | import type { GuideCommand } from '../interactions/guide.js'; 13 | import type { NodeCommand } from '../interactions/node.js'; 14 | import type { MDNIndexEntry } from '../types/mdn.js'; 15 | import { transformInteraction } from '../util/interactionOptions.js'; 16 | 17 | type CommandAutoCompleteName = 'discorddocs' | 'docs' | 'dtypes' | 'guide' | 'mdn' | 'node' | 'tag'; 18 | 19 | export async function handleApplicationCommandAutocomplete( 20 | res: Response, 21 | message: APIApplicationCommandAutocompleteInteraction, 22 | tagCache: Collection, 23 | mdnIndexCache: MDNIndexEntry[], 24 | ) { 25 | const data = message.data; 26 | const name = data.name as CommandAutoCompleteName; 27 | switch (name) { 28 | case 'node': { 29 | const args = transformInteraction(data.options); 30 | await nodeAutoComplete(res, args.query); 31 | break; 32 | } 33 | 34 | case 'docs': { 35 | await djsAutoComplete(res, data.options); 36 | break; 37 | } 38 | 39 | case 'tag': { 40 | tagAutoComplete(res, data.options, tagCache); 41 | break; 42 | } 43 | 44 | case 'guide': { 45 | const args = transformInteraction(data.options); 46 | await algoliaAutoComplete( 47 | res, 48 | args.query, 49 | process.env.DJS_GUIDE_ALGOLIA_APP!, 50 | process.env.DJS_GUIDE_ALGOLIA_KEY!, 51 | 'discordjs', 52 | ); 53 | break; 54 | } 55 | 56 | case 'discorddocs': { 57 | const args = transformInteraction(data.options); 58 | await algoliaAutoComplete( 59 | res, 60 | args.query, 61 | process.env.DDOCS_ALGOLIA_APP!, 62 | process.env.DDOCS_ALGOLIA_KEY!, 63 | 'discord', 64 | ); 65 | break; 66 | } 67 | 68 | case 'mdn': { 69 | mdnAutoComplete(res, data.options, mdnIndexCache); 70 | break; 71 | } 72 | 73 | case 'dtypes': { 74 | const args = transformInteraction(data.options); 75 | 76 | if (args.query === '') { 77 | res.end(JSON.stringify({ choices: [] })); 78 | return; 79 | } 80 | 81 | const prefix = (args.version ?? 'no-filter') === 'no-filter' ? '' : args.version!; 82 | const query = `${prefix} ${args.query}`.trim(); 83 | 84 | await algoliaAutoComplete( 85 | res, 86 | query, 87 | process.env.DTYPES_ALGOLIA_APP!, 88 | process.env.DTYPES_ALGOLIA_KEY!, 89 | 'discord-api-types', 90 | ); 91 | break; 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/handling/handleComponents.ts: -------------------------------------------------------------------------------- 1 | import type { APIMessageComponentInteraction } from 'discord-api-types/v10'; 2 | import { ComponentType } from 'discord-api-types/v10'; 3 | import type { Response } from 'polka'; 4 | import { testTagButton } from '../functions/components/testTagButton.js'; 5 | import { logger } from '../util/logger.js'; 6 | 7 | type ComponentName = 'testtag-clear'; 8 | 9 | export function handleComponent(res: Response, message: APIMessageComponentInteraction) { 10 | const data = message.data; 11 | const name = data.custom_id as ComponentName; 12 | switch (data.component_type) { 13 | case ComponentType.Button: { 14 | if (name === 'testtag-clear') { 15 | testTagButton(res); 16 | } 17 | 18 | break; 19 | } 20 | 21 | default: 22 | logger.info(data, `Received unknown component`); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/handling/handleModalSubmit.ts: -------------------------------------------------------------------------------- 1 | import type { APIModalSubmitInteraction } from 'discord-api-types/v10'; 2 | import type { Response } from 'polka'; 3 | import { testTagModalSubmit } from '../functions/modals/testTagModalSubmit.js'; 4 | 5 | type ModalSubmitName = 'testtag-hide' | 'testtag-show'; 6 | 7 | export async function handleModalSubmit(res: Response, message: APIModalSubmitInteraction) { 8 | const data = message.data; 9 | const name = data.custom_id as ModalSubmitName; 10 | switch (name) { 11 | case 'testtag-hide': 12 | case 'testtag-show': { 13 | await testTagModalSubmit(res, message); 14 | break; 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import { webcrypto } from 'node:crypto'; 3 | import process from 'node:process'; 4 | import { TextEncoder } from 'node:util'; 5 | import { Collection } from '@discordjs/collection'; 6 | import type { APIInteraction } from 'discord-api-types/v10'; 7 | import { InteractionType } from 'discord-api-types/v10'; 8 | import type { Middleware, NextHandler, Request, Response } from 'polka'; 9 | import polka from 'polka'; 10 | import { loadTags } from './functions/tag.js'; 11 | import type { Tag } from './functions/tag.js'; 12 | import { handleApplicationCommand } from './handling/handleApplicationCommand.js'; 13 | import { handleApplicationCommandAutocomplete } from './handling/handleApplicationCommandAutocomplete.js'; 14 | import { handleComponent } from './handling/handleComponents.js'; 15 | import { handleModalSubmit } from './handling/handleModalSubmit.js'; 16 | import type { MDNIndexEntry } from './types/mdn.js'; 17 | import { API_BASE_MDN, PREFIX_TEAPOT, PREFIX_BUG } from './util/constants.js'; 18 | import { reloadDjsVersions } from './util/djsdocs.js'; 19 | import { jsonParser } from './util/jsonParser.js'; 20 | import { logger } from './util/logger.js'; 21 | import { prepareAck, prepareResponse } from './util/respond.js'; 22 | 23 | if (process.env.ENVIRONMENT === 'debug') { 24 | logger.level = 'debug'; 25 | logger.debug('=== DEBUG LOGGING ENABLED ==='); 26 | } 27 | 28 | const { subtle } = webcrypto; 29 | 30 | const encoder = new TextEncoder(); 31 | 32 | function hex2bin(hex: string) { 33 | const buf = new Uint8Array(Math.ceil(hex.length / 2)); 34 | for (let index = 0; index < buf.length; index++) { 35 | buf[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16); 36 | } 37 | 38 | return buf; 39 | } 40 | 41 | const PUBKEY = await subtle.importKey('raw', hex2bin(process.env.DISCORD_PUBKEY!), 'Ed25519', true, ['verify']); 42 | 43 | const PORT = Number.parseInt(process.env.PORT!, 10); 44 | 45 | async function verify(req: Request, res: Response, next: NextHandler) { 46 | if (!req.headers['x-signature-ed25519']) { 47 | res.writeHead(401); 48 | return res.end(); 49 | } 50 | 51 | const signature = req.headers['x-signature-ed25519'] as string; 52 | const timestamp = req.headers['x-signature-timestamp'] as string; 53 | 54 | if (!signature || !timestamp) { 55 | res.writeHead(401); 56 | return res.end(); 57 | } 58 | 59 | const hexSignature = hex2bin(signature); 60 | const isValid = await subtle.verify('Ed25519', PUBKEY, hexSignature, encoder.encode(timestamp + req.rawBody)); 61 | 62 | if (!isValid) { 63 | res.statusCode = 401; 64 | return res.end(); 65 | } 66 | 67 | return next(); 68 | } 69 | 70 | const tagCache = new Collection(); 71 | const mdnIndexCache: MDNIndexEntry[] = []; 72 | await loadTags(tagCache); 73 | logger.info(`Tag cache loaded with ${tagCache.size} entries.`); 74 | await reloadDjsVersions(); 75 | 76 | export async function start() { 77 | const mdnData = (await fetch(`${API_BASE_MDN}/en-US/search-index.json`) 78 | .then(async (response) => response.json()) 79 | .catch(() => undefined)) as MDNIndexEntry[] | undefined; 80 | if (mdnData) { 81 | mdnIndexCache.push(...mdnData.map((entry) => ({ title: entry.title, url: entry.url }))); 82 | } 83 | 84 | polka() 85 | .use(jsonParser(), verify as Middleware) 86 | .post('/interactions', async (req, res) => { 87 | try { 88 | const message = req.body as APIInteraction; 89 | switch (message.type) { 90 | case InteractionType.Ping: 91 | prepareAck(res); 92 | break; 93 | case InteractionType.ApplicationCommand: 94 | await handleApplicationCommand(res, message, tagCache); 95 | break; 96 | case InteractionType.ApplicationCommandAutocomplete: 97 | await handleApplicationCommandAutocomplete(res, message, tagCache, mdnIndexCache); 98 | break; 99 | case InteractionType.ModalSubmit: 100 | await handleModalSubmit(res, message); 101 | break; 102 | case InteractionType.MessageComponent: 103 | handleComponent(res, message); 104 | break; 105 | 106 | default: 107 | prepareResponse(res, `${PREFIX_TEAPOT} This shouldn't be here...`, { ephemeral: true }); 108 | } 109 | } catch (error) { 110 | logger.error(error as Error); 111 | prepareResponse(res, `${PREFIX_BUG} Looks like something went wrong here, please try again later!`, { 112 | ephemeral: true, 113 | }); 114 | } 115 | 116 | res.end(); 117 | }) 118 | .listen(PORT); 119 | logger.info(`Listening for interactions on port ${PORT}.`); 120 | } 121 | 122 | process.on('uncaughtException', (err, origin) => { 123 | logger.error(`Caught exception: ${err.message}\nException origin: ${origin}`, err); 124 | }); 125 | 126 | process.on('unhandledRejection', (reason, promise) => { 127 | logger.error('Unhandled Rejection at:', promise, 'reason:', reason); 128 | }); 129 | 130 | void start(); 131 | -------------------------------------------------------------------------------- /src/interactions/discorddocs.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionType } from 'discord-api-types/v10'; 2 | 3 | export const DiscordDocsCommand = { 4 | name: 'discorddocs', 5 | description: 'Search Discord developer documentation', 6 | options: [ 7 | { 8 | type: ApplicationCommandOptionType.String, 9 | name: 'query', 10 | description: 'Phrase to search for', 11 | autocomplete: true, 12 | required: true, 13 | }, 14 | { 15 | type: ApplicationCommandOptionType.Boolean, 16 | name: 'hide', 17 | description: 'Hide command output', 18 | required: false, 19 | }, 20 | { 21 | type: ApplicationCommandOptionType.User, 22 | name: 'mention', 23 | description: 'User to mention', 24 | required: false, 25 | }, 26 | ], 27 | } as const; 28 | -------------------------------------------------------------------------------- /src/interactions/discordtypes.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionType } from 'discord-api-types/v10'; 2 | 3 | const QUERY_DESCRIPTION = 'Type, Enum or Interface to search for' as const; 4 | const VERSION_DESCRIPTION = 'Attempts to filter the results to the specified version' as const; 5 | const EPHEMERAL_DESCRIPTION = 'Hide command output' as const; 6 | 7 | export const DTypesCommand = { 8 | name: 'dtypes', 9 | description: 'Display discord-api-types documentation', 10 | options: [ 11 | { 12 | type: ApplicationCommandOptionType.String, 13 | name: 'query', 14 | description: QUERY_DESCRIPTION, 15 | required: true, 16 | autocomplete: true, 17 | }, 18 | { 19 | type: ApplicationCommandOptionType.String, 20 | name: 'version', 21 | description: VERSION_DESCRIPTION, 22 | required: false, 23 | choices: [ 24 | { 25 | name: 'No filter (default)', 26 | value: 'no-filter', 27 | }, 28 | { 29 | name: 'v10', 30 | value: 'v10', 31 | }, 32 | { 33 | name: 'v9', 34 | value: 'v9', 35 | }, 36 | { 37 | name: 'v8', 38 | value: 'v8', 39 | }, 40 | ], 41 | }, 42 | { 43 | type: ApplicationCommandOptionType.Boolean, 44 | name: 'hide', 45 | description: EPHEMERAL_DESCRIPTION, 46 | required: false, 47 | }, 48 | { 49 | type: ApplicationCommandOptionType.User, 50 | name: 'mention', 51 | description: 'User to mention', 52 | required: false, 53 | }, 54 | ], 55 | } as const; 56 | -------------------------------------------------------------------------------- /src/interactions/docs.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionType } from 'discord-api-types/v10'; 2 | import { type DjsVersions, reloadDjsVersions } from '../util/djsdocs.js'; 3 | 4 | const versions = await reloadDjsVersions(); 5 | export async function buildDocsCommand(versions: DjsVersions) { 6 | return { 7 | name: 'docs', 8 | description: 'Display discord.js documentation', 9 | options: [ 10 | { 11 | type: ApplicationCommandOptionType.String, 12 | name: 'query', 13 | description: 'Phrase to search for', 14 | required: true, 15 | autocomplete: true, 16 | }, 17 | { 18 | type: ApplicationCommandOptionType.Boolean, 19 | name: 'hide', 20 | description: 'Hide command output (default: False)', 21 | required: false, 22 | }, 23 | { 24 | type: ApplicationCommandOptionType.String, 25 | name: 'version', 26 | description: 'Version of discord.js to use (default: Latest release)', 27 | choices: versions.versions 28 | .get('discord.js') 29 | ?.slice(0, 25) 30 | .map((version) => { 31 | return { 32 | name: version, 33 | value: version, 34 | }; 35 | }) ?? [ 36 | { 37 | name: 'main', 38 | value: 'main', 39 | }, 40 | ], 41 | required: false, 42 | }, 43 | { 44 | type: ApplicationCommandOptionType.User, 45 | name: 'mention', 46 | description: 'User to mention', 47 | required: false, 48 | }, 49 | ], 50 | } as const; 51 | } 52 | 53 | export const DocsCommand = await buildDocsCommand(versions); 54 | -------------------------------------------------------------------------------- /src/interactions/guide.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionType } from 'discord-api-types/v10'; 2 | 3 | export const GuideCommand = { 4 | name: 'guide', 5 | description: 'Search discordjs.guide', 6 | options: [ 7 | { 8 | type: ApplicationCommandOptionType.String, 9 | name: 'query', 10 | description: 'Phrase to search for', 11 | autocomplete: true, 12 | required: true, 13 | }, 14 | { 15 | type: ApplicationCommandOptionType.Boolean, 16 | name: 'hide', 17 | description: 'Hide command output', 18 | required: false, 19 | }, 20 | { 21 | type: ApplicationCommandOptionType.User, 22 | name: 'mention', 23 | description: 'User to mention', 24 | required: false, 25 | }, 26 | ], 27 | } as const; 28 | -------------------------------------------------------------------------------- /src/interactions/mdn.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionType } from 'discord-api-types/v10'; 2 | 3 | export const MdnCommand = { 4 | name: 'mdn', 5 | description: 'Search the Mozilla Developer Network documentation', 6 | options: [ 7 | { 8 | type: ApplicationCommandOptionType.String, 9 | name: 'query', 10 | description: 'Class or method to search for', 11 | required: true, 12 | autocomplete: true, 13 | }, 14 | { 15 | type: ApplicationCommandOptionType.Boolean, 16 | name: 'hide', 17 | description: 'Hide command output', 18 | required: false, 19 | }, 20 | { 21 | type: ApplicationCommandOptionType.User, 22 | name: 'mention', 23 | description: 'User to mention', 24 | required: false, 25 | }, 26 | ], 27 | } as const; 28 | -------------------------------------------------------------------------------- /src/interactions/node.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionType } from 'discord-api-types/v10'; 2 | 3 | export const NodeCommand = { 4 | name: 'node', 5 | description: 'Search the Node.js documentation', 6 | options: [ 7 | { 8 | type: ApplicationCommandOptionType.String, 9 | name: 'query', 10 | description: 'Phrase to search for', 11 | required: true, 12 | autocomplete: true, 13 | }, 14 | { 15 | type: ApplicationCommandOptionType.Boolean, 16 | name: 'hide', 17 | description: 'Hide command output', 18 | required: false, 19 | }, 20 | { 21 | type: ApplicationCommandOptionType.User, 22 | name: 'mention', 23 | description: 'User to mention', 24 | required: false, 25 | }, 26 | ], 27 | } as const; 28 | -------------------------------------------------------------------------------- /src/interactions/reloadVersioncache.ts: -------------------------------------------------------------------------------- 1 | export const DjsVersionReloadCommand = { 2 | name: 'reloadversions', 3 | description: 'Reload discord.js packages to match the latest version', 4 | default_member_permissions: '0', 5 | } as const; 6 | -------------------------------------------------------------------------------- /src/interactions/tag.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionType } from 'discord-api-types/v10'; 2 | 3 | export const TagCommand = { 4 | name: 'tag', 5 | description: 'Send a tag by name or alias', 6 | options: [ 7 | { 8 | type: ApplicationCommandOptionType.String, 9 | name: 'query', 10 | description: 'Tag name or alias', 11 | required: true, 12 | autocomplete: true, 13 | }, 14 | { 15 | type: ApplicationCommandOptionType.Boolean, 16 | name: 'hide', 17 | description: 'Hide command output', 18 | required: false, 19 | }, 20 | { 21 | type: ApplicationCommandOptionType.User, 22 | name: 'mention', 23 | description: 'User to mention', 24 | required: false, 25 | }, 26 | ], 27 | } as const; 28 | -------------------------------------------------------------------------------- /src/interactions/tagreload.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionType } from 'discord-api-types/v10'; 2 | 3 | export const TagReloadCommand = { 4 | name: 'tagreload', 5 | description: 'Reload tags', 6 | options: [ 7 | { 8 | type: ApplicationCommandOptionType.Boolean, 9 | name: 'remote', 10 | description: 'Use remote repository tags (default: false, use local files)', 11 | required: false, 12 | }, 13 | ], 14 | default_member_permissions: '0', 15 | } as const; 16 | -------------------------------------------------------------------------------- /src/interactions/testtag.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionType } from 'discord-api-types/v10'; 2 | 3 | export const TestTagCommand = { 4 | name: 'testtag', 5 | description: 'Show and validate a tag (TOML modal input)', 6 | options: [ 7 | { 8 | type: ApplicationCommandOptionType.Boolean, 9 | name: 'hide', 10 | description: 'Hide command output (default: true)', 11 | required: false, 12 | }, 13 | ], 14 | } as const; 15 | -------------------------------------------------------------------------------- /src/types/NodeDocs.d.ts: -------------------------------------------------------------------------------- 1 | // Autogenerated file 2 | 3 | export type NodeDocs = { 4 | classes: PurpleClass[]; 5 | globals: NodeDocsGlobal[]; 6 | methods: NodeDocsMethod[]; 7 | miscs: NodeDocsMisc[]; 8 | modules: NodeDocsModule[]; 9 | }; 10 | 11 | export type PurpleClass = { 12 | [key: string]: any; 13 | desc?: string; 14 | displayName?: string; 15 | meta?: PurpleMeta; 16 | methods?: PurpleMethod[]; 17 | modules?: PurpleModule[]; 18 | name: string; 19 | properties?: ModuleElement[]; 20 | signatures?: StickySignature[]; 21 | source?: string; 22 | textRaw: string; 23 | type: TypeEnum; 24 | }; 25 | 26 | export type PurpleMeta = { 27 | added?: string[]; 28 | changes: PurpleChange[]; 29 | }; 30 | 31 | export type PurpleChange = { 32 | description: string; 33 | 'pr-url': string; 34 | version: string[] | string; 35 | }; 36 | 37 | export type PurpleMethod = { 38 | desc: string; 39 | name: string; 40 | signatures: PurpleSignature[]; 41 | textRaw: string; 42 | type: MethodType; 43 | }; 44 | 45 | export type PurpleSignature = { 46 | params: PurpleParam[]; 47 | }; 48 | 49 | export type PurpleParam = { 50 | name: string; 51 | textRaw: string; 52 | type: ParamType; 53 | }; 54 | 55 | export enum ParamType { 56 | Function = 'Function', 57 | Number = 'number', 58 | Object = 'Object', 59 | String = 'string', 60 | StringBufferUint8Array = 'string|Buffer|Uint8Array', 61 | StringString = 'string|string[]', 62 | } 63 | 64 | export enum MethodType { 65 | Method = 'method', 66 | } 67 | 68 | export type PurpleModule = { 69 | desc: string; 70 | displayName: string; 71 | name: string; 72 | textRaw: string; 73 | type: TypeEnum; 74 | }; 75 | 76 | export enum TypeEnum { 77 | Class = 'class', 78 | Misc = 'misc', 79 | Module = 'module', 80 | } 81 | 82 | export type FluffyReturn = { 83 | desc?: string; 84 | name: Name; 85 | options?: EventElement[]; 86 | textRaw: string; 87 | type: string; 88 | }; 89 | 90 | export type IndigoParam = { 91 | default?: string; 92 | desc?: string; 93 | name: string; 94 | options?: EventElement[]; 95 | textRaw: string; 96 | type: string; 97 | }; 98 | 99 | export type EventSignature = { 100 | params: IndigoParam[]; 101 | return?: EventElement; 102 | }; 103 | 104 | export type MethodElement = { 105 | default?: string; 106 | desc?: string; 107 | meta?: PurpleMeta; 108 | name?: string; 109 | options?: MethodElement[]; 110 | params?: ModuleElement[]; 111 | properties?: ModuleElement[]; 112 | signatures?: EventSignature[]; 113 | textRaw: string; 114 | type?: string; 115 | }; 116 | 117 | export type StickyParam = { 118 | desc?: string; 119 | name: string; 120 | options?: MethodElement[]; 121 | textRaw: string; 122 | type: string; 123 | }; 124 | 125 | export type FluffySignature = { 126 | params: StickyParam[]; 127 | return?: FluffyReturn; 128 | }; 129 | 130 | export type TentacledParam = { 131 | desc?: string; 132 | name: string; 133 | options?: ModuleElement[]; 134 | textRaw: string; 135 | type: string; 136 | }; 137 | 138 | export type EventElement = { 139 | default?: string; 140 | desc?: string; 141 | displayName?: string; 142 | meta?: PurpleMeta; 143 | methods?: PropertyElement[]; 144 | modules?: PurpleModule[]; 145 | name: string; 146 | options?: EventElement[]; 147 | params?: TentacledParam[]; 148 | shortDesc?: string; 149 | signatures?: FluffySignature[]; 150 | stability?: number; 151 | stabilityText?: string; 152 | textRaw: string; 153 | type?: string; 154 | }; 155 | 156 | export type ModuleElement = { 157 | default?: string; 158 | desc?: string; 159 | displayName?: string; 160 | events?: EventElement[]; 161 | meta?: FluffyMeta; 162 | miscs?: ModuleElement[]; 163 | name?: string; 164 | options?: ModuleElement[]; 165 | params?: ModuleElement[]; 166 | signatures?: TentacledSignature[]; 167 | textRaw: string; 168 | type?: string; 169 | }; 170 | 171 | export enum Name { 172 | Return = 'return', 173 | } 174 | 175 | export type PropertyElement = { 176 | default?: string; 177 | desc?: string; 178 | displayName?: string; 179 | meta?: PurpleMeta; 180 | miscs?: PropertyElement[]; 181 | modules?: PropertyElement[]; 182 | name?: string; 183 | options?: PropertyElement[]; 184 | params?: any[]; 185 | shortDesc?: string; 186 | signatures?: CtorSignature[]; 187 | textRaw: string; 188 | type?: string; 189 | }; 190 | 191 | export type CtorSignature = { 192 | params: FluffyParam[]; 193 | return?: PurpleReturn; 194 | }; 195 | 196 | export type FluffyParam = { 197 | default?: string; 198 | desc?: string; 199 | name: string; 200 | options?: OptionElement[]; 201 | textRaw: string; 202 | type: ParamType; 203 | }; 204 | 205 | export type OptionElement = { 206 | default?: string; 207 | desc?: string; 208 | name: string; 209 | textRaw: string; 210 | type: string; 211 | }; 212 | 213 | export type PurpleReturn = { 214 | desc?: string; 215 | name: Name; 216 | textRaw: string; 217 | type: string; 218 | }; 219 | 220 | export type FluffyMeta = { 221 | added?: string[]; 222 | changes: FluffyChange[]; 223 | }; 224 | 225 | export type FluffyChange = { 226 | description: string; 227 | 'pr-url': string; 228 | version: string; 229 | }; 230 | 231 | export type TentacledSignature = { 232 | params: OptionElement[]; 233 | return?: PurpleReturn; 234 | }; 235 | 236 | export type StickySignature = { 237 | desc: string; 238 | params: PurpleParam[]; 239 | }; 240 | 241 | export type NodeDocsGlobal = { 242 | classes?: GlobalClass[]; 243 | desc: string; 244 | introduced_in?: string; 245 | meta?: PurpleMeta; 246 | methods?: GlobalMethod[]; 247 | modules?: ModuleElement[]; 248 | name: string; 249 | properties?: GlobalProperty[]; 250 | source: Source; 251 | textRaw: string; 252 | type: GlobalType; 253 | }; 254 | 255 | export type GlobalClass = { 256 | classMethods?: EventElement[]; 257 | desc?: string; 258 | events?: PurpleEvent[]; 259 | meta?: PurpleMeta; 260 | methods?: FluffyMethod[]; 261 | name: string; 262 | properties?: PurpleProperty[]; 263 | textRaw: string; 264 | type: TypeEnum; 265 | }; 266 | 267 | export type PurpleEvent = { 268 | desc: string; 269 | meta: PurpleMeta; 270 | name: string; 271 | params: ModuleElement[]; 272 | textRaw: string; 273 | type: EventType; 274 | }; 275 | 276 | export enum EventType { 277 | Event = 'event', 278 | } 279 | 280 | export type FluffyMethod = { 281 | desc: string; 282 | meta: PurpleMeta; 283 | name: string; 284 | signatures: IndigoSignature[]; 285 | stability?: number; 286 | stabilityText?: MethodStabilityText; 287 | textRaw: string; 288 | type: MethodType; 289 | }; 290 | 291 | export type IndigoSignature = { 292 | params: MethodElement[]; 293 | return?: MethodElement; 294 | }; 295 | 296 | export enum MethodStabilityText { 297 | DeprecatedUseSubpathPatternsInstead = 'Deprecated: Use subpath patterns instead.', 298 | Experimental = 'Experimental', 299 | } 300 | 301 | export type PurpleProperty = { 302 | desc: string; 303 | meta: PurpleMeta; 304 | name: string; 305 | textRaw: string; 306 | type: string; 307 | }; 308 | 309 | export type GlobalMethod = { 310 | desc: string; 311 | meta: PurpleMeta; 312 | modules?: ModuleElement[]; 313 | name: string; 314 | signatures: IndigoSignature[]; 315 | stability?: number; 316 | stabilityText?: string; 317 | textRaw: string; 318 | type: MethodType; 319 | }; 320 | 321 | export type GlobalProperty = { 322 | desc?: string; 323 | meta?: EventMeta; 324 | methods?: EventElement[]; 325 | modules?: PropertyElement[]; 326 | name: string; 327 | properties?: PropertyElement[]; 328 | stability?: number; 329 | stabilityText?: string; 330 | textRaw: string; 331 | type: string; 332 | }; 333 | 334 | export type EventMeta = { 335 | added?: string[]; 336 | changes: TentacledChange[]; 337 | deprecated?: string[]; 338 | }; 339 | 340 | export type TentacledChange = { 341 | commit?: string; 342 | description: string; 343 | 'pr-url'?: string; 344 | version: string[] | string; 345 | }; 346 | 347 | export enum Source { 348 | DocAPIGlobalsMd = 'doc/api/globals.md', 349 | DocAPIProcessMd = 'doc/api/process.md', 350 | } 351 | 352 | export enum GlobalType { 353 | Global = 'global', 354 | } 355 | 356 | export type NodeDocsMethod = { 357 | desc: string; 358 | meta?: PurpleMeta; 359 | name: string; 360 | signatures: PurpleSignature[]; 361 | source: Source; 362 | stability?: number; 363 | stabilityText?: string; 364 | textRaw: string; 365 | type: MethodType; 366 | }; 367 | 368 | export type NodeDocsMisc = { 369 | classes?: PurpleClass[]; 370 | desc?: string; 371 | globals?: MiscGlobal[]; 372 | introduced_in: string; 373 | meta?: PurpleMeta; 374 | methods?: EventElement[]; 375 | miscs: PurpleMisc[]; 376 | name: string; 377 | properties?: MiscProperty[]; 378 | source: string; 379 | stability?: number; 380 | stabilityText?: MiscStabilityText; 381 | textRaw: string; 382 | type: TypeEnum; 383 | }; 384 | 385 | export type MiscGlobal = { 386 | classes?: GlobalClass[]; 387 | desc: string; 388 | meta: PurpleMeta; 389 | methods?: EventElement[]; 390 | name: string; 391 | properties?: PropertyElement[]; 392 | textRaw: string; 393 | type: GlobalType; 394 | }; 395 | 396 | export type PurpleMisc = { 397 | desc?: string; 398 | displayName?: string; 399 | meta?: PurpleMeta; 400 | miscs?: PurpleClass[]; 401 | modules?: MiscModule[]; 402 | name: string; 403 | properties?: MethodElement[]; 404 | stability?: number; 405 | stabilityText?: string; 406 | textRaw: string; 407 | type: TypeEnum; 408 | }; 409 | 410 | export type MiscModule = { 411 | desc?: string; 412 | displayName: string; 413 | meta?: TentacledMeta; 414 | modules?: FluffyModule[]; 415 | name: string; 416 | properties?: MethodElement[]; 417 | stability?: number; 418 | stabilityText?: MethodStabilityText; 419 | textRaw: string; 420 | type: TypeEnum; 421 | }; 422 | 423 | export type TentacledMeta = { 424 | added?: string[]; 425 | changes: StickyChange[]; 426 | napiVersion?: number[]; 427 | removed?: string[]; 428 | }; 429 | 430 | export type StickyChange = { 431 | commit?: string; 432 | description: string; 433 | 'pr-url'?: string[] | string; 434 | version: string[] | string; 435 | }; 436 | 437 | export type FluffyModule = { 438 | desc: string; 439 | displayName: string; 440 | meta?: StickyMeta; 441 | name: string; 442 | stability?: number; 443 | stabilityText?: MethodStabilityText; 444 | textRaw: string; 445 | type: TypeEnum; 446 | }; 447 | 448 | export type StickyMeta = { 449 | added?: string[]; 450 | changes: TentacledChange[]; 451 | napiVersion?: number[]; 452 | }; 453 | 454 | export type MiscProperty = { 455 | desc: string; 456 | methods: EventElement[]; 457 | name: string; 458 | properties: EventElement[]; 459 | textRaw: string; 460 | type: ParamType; 461 | }; 462 | 463 | export enum MiscStabilityText { 464 | Deprecated = 'Deprecated', 465 | Experimental = 'Experimental', 466 | Legacy = 'Legacy', 467 | Stable = 'Stable', 468 | } 469 | 470 | export type NodeDocsModule = { 471 | classes?: FluffyClass[]; 472 | desc?: string; 473 | displayName?: string; 474 | events?: MethodElement[]; 475 | introduced_in?: string; 476 | meta?: IndigoMeta; 477 | methods?: StickyMethod[]; 478 | miscs?: ModuleMisc[]; 479 | modules?: TentacledModule[]; 480 | name: string; 481 | properties?: IndigoProperty[]; 482 | source: string; 483 | stability?: number; 484 | stabilityText?: MiscStabilityText; 485 | textRaw: string; 486 | type: TypeEnum; 487 | vars?: FluffyVar[]; 488 | }; 489 | 490 | export type FluffyClass = { 491 | classMethods?: ModuleElement[]; 492 | desc?: string; 493 | events?: FluffyEvent[]; 494 | meta?: EventMeta; 495 | methods?: TentacledMethod[]; 496 | modules?: ClassModule[]; 497 | name: string; 498 | properties?: FluffyProperty[]; 499 | signatures?: IndecentSignature[]; 500 | stability?: number; 501 | stabilityText?: string; 502 | textRaw: string; 503 | type: TypeEnum; 504 | }; 505 | 506 | export type FluffyEvent = { 507 | desc: string; 508 | meta?: EventMeta; 509 | name: string; 510 | params: PropertyElement[]; 511 | textRaw: string; 512 | type: EventType; 513 | }; 514 | 515 | export type TentacledMethod = { 516 | desc?: string; 517 | meta?: EventMeta; 518 | methods?: PropertyElement[]; 519 | modules?: PropertyElement[]; 520 | name: string; 521 | properties?: MethodElement[]; 522 | signatures: IndigoSignature[]; 523 | stability?: number; 524 | stabilityText?: string; 525 | textRaw: string; 526 | type: MethodType; 527 | }; 528 | 529 | export type ClassModule = { 530 | ctors?: EventElement[]; 531 | desc: string; 532 | displayName: string; 533 | meta?: PurpleMeta; 534 | methods?: EventElement[]; 535 | modules?: PropertyElement[]; 536 | name: string; 537 | stability?: number; 538 | stabilityText?: string; 539 | textRaw: string; 540 | type: TypeEnum; 541 | }; 542 | 543 | export type FluffyProperty = { 544 | default?: string; 545 | desc?: string; 546 | meta?: EventMeta; 547 | methods?: EventElement[]; 548 | name: string; 549 | options?: EventElement[]; 550 | shortDesc?: string; 551 | stability?: number; 552 | stabilityText?: string; 553 | textRaw: string; 554 | type?: string; 555 | }; 556 | 557 | export type IndecentSignature = { 558 | desc?: string; 559 | params: MethodElement[]; 560 | return?: MethodElement; 561 | }; 562 | 563 | export type IndigoMeta = { 564 | added?: string[]; 565 | changes: FluffyChange[]; 566 | deprecated?: string[]; 567 | }; 568 | 569 | export type StickyMethod = { 570 | desc?: string; 571 | meta?: EventMeta; 572 | methods?: PropertyElement[]; 573 | miscs?: PropertyElement[]; 574 | modules?: PropertyElement[]; 575 | name: string; 576 | properties?: PropertyElement[]; 577 | signatures: HilariousSignature[]; 578 | stability?: number; 579 | stabilityText?: string; 580 | textRaw: string; 581 | type: MethodType; 582 | }; 583 | 584 | export type HilariousSignature = { 585 | params: PropertyElement[]; 586 | return?: PropertyElement; 587 | }; 588 | 589 | export type ModuleMisc = { 590 | desc?: string; 591 | introduced_in?: string; 592 | meta?: PurpleMeta; 593 | methods?: MethodElement[]; 594 | miscs?: FluffyMisc[]; 595 | name: string; 596 | textRaw: string; 597 | type: TypeEnum; 598 | }; 599 | 600 | export type FluffyMisc = { 601 | classes?: GlobalClass[]; 602 | ctors?: PropertyElement[]; 603 | desc?: string; 604 | displayName?: string; 605 | events?: ModuleElement[]; 606 | examples?: ModuleElement[]; 607 | meta?: PurpleMeta; 608 | methods?: IndigoMethod[]; 609 | miscs?: ModuleElement[]; 610 | modules?: ModuleElement[]; 611 | name: string; 612 | textRaw: string; 613 | type: TypeEnum; 614 | }; 615 | 616 | export type IndigoMethod = { 617 | desc: string; 618 | meta?: PurpleMeta; 619 | name: string; 620 | signatures: AmbitiousSignature[]; 621 | textRaw: string; 622 | type: MethodType; 623 | }; 624 | 625 | export type AmbitiousSignature = { 626 | params: ModuleElement[]; 627 | return?: ModuleElement; 628 | }; 629 | 630 | export type TentacledModule = { 631 | classes?: TentacledClass[]; 632 | desc?: string; 633 | displayName: string; 634 | meta?: PurpleMeta; 635 | methods?: IndecentMethod[]; 636 | miscs?: EventElement[]; 637 | modules?: StickyModule[]; 638 | name: string; 639 | properties?: StickyProperty[]; 640 | stability?: number; 641 | stabilityText?: string; 642 | textRaw: string; 643 | type: TypeEnum; 644 | vars?: PurpleVar[]; 645 | }; 646 | 647 | export type TentacledClass = { 648 | desc?: string; 649 | events?: EventElement[]; 650 | meta?: IndigoMeta; 651 | methods?: MethodElement[]; 652 | modules?: EventElement[]; 653 | name: string; 654 | properties?: TentacledProperty[]; 655 | signatures?: CunningSignature[]; 656 | stability?: number; 657 | stabilityText?: string; 658 | textRaw: string; 659 | type: TypeEnum; 660 | }; 661 | 662 | export type TentacledProperty = { 663 | default?: string; 664 | desc: string; 665 | meta?: IndigoMeta; 666 | modules?: ModuleElement[]; 667 | name: string; 668 | shortDesc?: string; 669 | stability?: number; 670 | stabilityText?: string; 671 | textRaw: string; 672 | type?: string; 673 | }; 674 | 675 | export type CunningSignature = { 676 | desc: string; 677 | params: ModuleElement[]; 678 | }; 679 | 680 | export type IndecentMethod = { 681 | desc?: string; 682 | meta?: EventMeta; 683 | miscs?: ModuleElement[]; 684 | modules?: ModuleElement[]; 685 | name: string; 686 | properties?: ModuleElement[]; 687 | signatures: MagentaSignature[]; 688 | stability?: number; 689 | stabilityText?: string; 690 | textRaw: string; 691 | type: MethodType; 692 | }; 693 | 694 | export type MagentaSignature = { 695 | params: EventElement[]; 696 | return?: EventElement; 697 | }; 698 | 699 | export type StickyModule = { 700 | classes?: MethodElement[]; 701 | desc?: string; 702 | displayName: string; 703 | meta?: PurpleMeta; 704 | methods?: ModuleElement[]; 705 | modules?: PurpleClass[]; 706 | name: string; 707 | properties?: PropertyElement[]; 708 | stability?: number; 709 | stabilityText?: string; 710 | textRaw: string; 711 | type: TypeEnum; 712 | }; 713 | 714 | export type StickyProperty = { 715 | default?: string; 716 | desc?: string; 717 | meta?: EventMeta; 718 | methods?: PropertyElement[]; 719 | modules?: PropertyElement[]; 720 | name: string; 721 | properties?: PropertyElement[]; 722 | shortDesc?: string; 723 | stability?: number; 724 | stabilityText?: MiscStabilityText; 725 | textRaw: string; 726 | type?: string; 727 | }; 728 | 729 | export type PurpleVar = { 730 | desc: string; 731 | meta: PurpleMeta; 732 | methods?: EventElement[]; 733 | name: string; 734 | properties?: VarMethod[]; 735 | textRaw: string; 736 | type: string; 737 | }; 738 | 739 | export type VarMethod = { 740 | desc: string; 741 | meta: EventMeta; 742 | modules?: ModuleElement[]; 743 | name: string; 744 | signatures?: AmbitiousSignature[]; 745 | stability?: number; 746 | stabilityText?: string; 747 | textRaw: string; 748 | type: string; 749 | }; 750 | 751 | export type IndigoProperty = { 752 | default?: string; 753 | desc?: string; 754 | meta?: EventMeta; 755 | methods?: VarMethod[]; 756 | name: string; 757 | options?: ModuleElement[]; 758 | properties?: ModuleElement[]; 759 | shortDesc?: string; 760 | stability?: number; 761 | stabilityText?: string; 762 | textRaw: string; 763 | type?: string; 764 | }; 765 | 766 | export type FluffyVar = { 767 | desc: string; 768 | meta: PurpleMeta; 769 | methods: ModuleElement[]; 770 | name: TypeEnum; 771 | properties: VarMethod[]; 772 | textRaw: string; 773 | type: string; 774 | }; 775 | -------------------------------------------------------------------------------- /src/types/algolia.ts: -------------------------------------------------------------------------------- 1 | export type AlgoliaSearchResult = { 2 | hits?: AlgoliaHit[]; 3 | query: string; 4 | }; 5 | 6 | export type AlgoliaHit = { 7 | anchor: string; 8 | content: string | null; 9 | hierarchy: AlgoliaHitHierarchy; 10 | objectID: string; 11 | url: string; 12 | }; 13 | 14 | export type AlgoliaHitHierarchy = { 15 | lvl0: string | null; 16 | lvl1: string | null; 17 | lvl2: string | null; 18 | lvl3: string | null; 19 | lvl4: string | null; 20 | lvl5: string | null; 21 | lvl6: string | null; 22 | }; 23 | -------------------------------------------------------------------------------- /src/types/mdn.ts: -------------------------------------------------------------------------------- 1 | export type MDNIndexEntry = { 2 | title: string; 3 | url: string; 4 | }; 5 | -------------------------------------------------------------------------------- /src/util/argumentsOf.ts: -------------------------------------------------------------------------------- 1 | import type { ApplicationCommandOptionType } from 'discord-api-types/v10'; 2 | 3 | export type Command = Readonly<{ 4 | description: string; 5 | name: string; 6 | options?: readonly Option[]; 7 | }>; 8 | 9 | type Option = Readonly< 10 | { 11 | description: string; 12 | name: string; 13 | required?: boolean; 14 | } & ( 15 | | { 16 | choices?: readonly Readonly<{ name: string; value: number }>[]; 17 | type: ApplicationCommandOptionType.Integer | ApplicationCommandOptionType.Number; 18 | } 19 | | { 20 | choices?: readonly Readonly<{ name: string; value: string }>[]; 21 | type: ApplicationCommandOptionType.String; 22 | } 23 | | { 24 | options?: readonly Option[]; 25 | type: ApplicationCommandOptionType.Subcommand | ApplicationCommandOptionType.SubcommandGroup; 26 | } 27 | | { 28 | type: 29 | | ApplicationCommandOptionType.Attachment 30 | | ApplicationCommandOptionType.Boolean 31 | | ApplicationCommandOptionType.Channel 32 | | ApplicationCommandOptionType.Mentionable 33 | | ApplicationCommandOptionType.Role 34 | | ApplicationCommandOptionType.User; 35 | } 36 | ) 37 | >; 38 | 39 | type UnionToIntersection = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never; 40 | 41 | type TypeIdToType = T extends ApplicationCommandOptionType.Subcommand 42 | ? ArgumentsOfRaw 43 | : T extends ApplicationCommandOptionType.SubcommandGroup 44 | ? ArgumentsOfRaw 45 | : T extends ApplicationCommandOptionType.String 46 | ? C extends readonly { value: string }[] 47 | ? C[number]['value'] 48 | : string 49 | : T extends ApplicationCommandOptionType.Integer | ApplicationCommandOptionType.Number 50 | ? C extends readonly { value: number }[] 51 | ? C[number]['value'] 52 | : number 53 | : T extends ApplicationCommandOptionType.Boolean 54 | ? boolean 55 | : T extends ApplicationCommandOptionType.User 56 | ? string 57 | : T extends ApplicationCommandOptionType.Channel 58 | ? string 59 | : T extends ApplicationCommandOptionType.Role 60 | ? string 61 | : T extends ApplicationCommandOptionType.Mentionable 62 | ? string 63 | : never; 64 | 65 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 66 | type OptionToObject = O extends { 67 | choices?: infer C; 68 | name: infer K; 69 | options?: infer O; 70 | required?: infer R; 71 | type: infer T; 72 | } 73 | ? K extends string 74 | ? R extends true 75 | ? { [k in K]: TypeIdToType } 76 | : T extends ApplicationCommandOptionType.Subcommand | ApplicationCommandOptionType.SubcommandGroup 77 | ? { [k in K]: TypeIdToType } 78 | : { [k in K]?: TypeIdToType } 79 | : never 80 | : never; 81 | 82 | type ArgumentsOfRaw = O extends readonly any[] ? UnionToIntersection> : never; 83 | 84 | export type ArgumentsOf = C extends { options: readonly Option[] } 85 | ? UnionToIntersection> 86 | : unknown; 87 | -------------------------------------------------------------------------------- /src/util/compactAlgoliaId.ts: -------------------------------------------------------------------------------- 1 | const replacements = { 2 | 'https://discord-api-types.dev/api/next/discord-api-types-': '{{FULL_DTYPES}}', 3 | 'https://discord-api-types.dev/api/': '{{DTYPES}}', 4 | 'discord-api-types-': '{{D-API}}', 5 | }; 6 | 7 | export function compactAlgoliaObjectId(url: string): string { 8 | let res = url; 9 | for (const [key, value] of Object.entries(replacements)) { 10 | res = res.replace(key, value); 11 | } 12 | 13 | return res; 14 | } 15 | 16 | export function expandAlgoliaObjectId(url: string): string { 17 | let res = url; 18 | for (const [key, value] of Object.entries(replacements)) { 19 | res = res.replace(value, key); 20 | } 21 | 22 | return res; 23 | } 24 | -------------------------------------------------------------------------------- /src/util/constants.ts: -------------------------------------------------------------------------------- 1 | export const PREFIX_FAIL = '`❌`' as const; 2 | export const PREFIX_SUCCESS = '`✅`' as const; 3 | export const PREFIX_BUG = '`🐞`' as const; 4 | export const PREFIX_TEAPOT = '`🍵418`' as const; 5 | export const EMOJI_ID_DJS = '1263786117040177182' as const; 6 | export const EMOJI_ID_DJS_DEV = '1263786105082347560' as const; 7 | export const EMOJI_ID_GUIDE = '1263786094135345293' as const; 8 | export const EMOJI_ID_MDN = '1263782067620151296' as const; 9 | export const EMOJI_ID_NODE = '1263782050322714695' as const; 10 | export const EMOJI_ID_FIELD = '1263782602939174954' as const; 11 | export const EMOJI_ID_METHOD = '1263782570915659898' as const; 12 | export const EMOJI_ID_CLASS = '1263782539366109186' as const; 13 | export const EMOJI_ID_EVENT = '1263782515202719744' as const; 14 | export const EMOJI_ID_ENUM = '1263782475755290635' as const; 15 | export const EMOJI_ID_INTERFACE = '1263782426203787275' as const; 16 | export const EMOJI_ID_FIELD_DEV = '1263782260730105887' as const; 17 | export const EMOJI_ID_METHOD_DEV = '1263782243424272384' as const; 18 | export const EMOJI_ID_CLASS_DEV = '1263782223245738006' as const; 19 | export const EMOJI_ID_EVENT_DEV = '1263782209412796469' as const; 20 | export const EMOJI_ID_ENUM_DEV = '1263782170833588340' as const; 21 | export const EMOJI_ID_INTERFACE_DEV = '1263782120334299249' as const; 22 | export const EMOJI_ID_VARIABLE_DEV = '1263782097286463538' as const; 23 | export const EMOJI_ID_VARIABLE = '1263782405865472102' as const; 24 | export const EMOJI_ID_CLYDE_BLURPLE = '1263782079833968692' as const; 25 | export const EMOJI_ID_NO_TEST = '1263785410853605397' as const; 26 | export const EMOJI_ID_DTYPES = '1263786781669724232' as const; 27 | export const API_BASE_MDN = 'https://developer.mozilla.org' as const; 28 | export const API_BASE_NODE = 'https://nodejs.org' as const; 29 | export const API_BASE_ALGOLIA = 'algolia.net' as const; 30 | export const API_BASE_DISCORD = 'https://discord.com/api/v9' as const; 31 | export const API_BASE_ORAMA = 'https://cloud.orama.run/v1' as const; 32 | export const AUTOCOMPLETE_MAX_ITEMS = 25; 33 | export const AUTOCOMPLETE_MAX_NAME_LENGTH = 100; 34 | export const MAX_MESSAGE_LENGTH = 4_000; 35 | export const REMOTE_TAG_URL = 'https://raw.githubusercontent.com/discordjs/discord-utils-bot/main/tags' as const; 36 | export const WEBSITE_URL_ROOT = 'https://discordjs.dev'; 37 | export const DJS_DOCS_BASE = 'https://discord.js.org/docs'; 38 | export const DEFAULT_DOCS_BRANCH = 'stable' as const; 39 | export const VALIDATION_FAIL_COLOR = 0xed4245 as const; 40 | export const VALIDATION_SUCCESS_COLOR = 0x3ba55d as const; 41 | export const VALIDATION_WARNING_COLOR = 0xffdb5c as const; 42 | export const DJS_QUERY_SEPARATOR = '|' as const; 43 | -------------------------------------------------------------------------------- /src/util/dedupe.ts: -------------------------------------------------------------------------------- 1 | import type { AlgoliaHit } from '../types/algolia.js'; 2 | 3 | export function dedupeAlgoliaHits(): (hit: AlgoliaHit) => boolean { 4 | const dedupe = new Set(); 5 | return (hit: AlgoliaHit) => { 6 | const dedupeIdentifier = Object.values(hit.hierarchy).join('::'); 7 | return Boolean(!dedupe.has(dedupeIdentifier) && dedupe.add(dedupeIdentifier)); 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /src/util/discordDocs.ts: -------------------------------------------------------------------------------- 1 | import { toTitlecase } from './misc.js'; 2 | import { urlOption } from './url.js'; 3 | 4 | export function toMdFilename(name: string) { 5 | return name 6 | .split('-') 7 | .map((part) => toTitlecase(part)) 8 | .join('_'); 9 | } 10 | 11 | export function resolveResourceFromDocsURL(link: string) { 12 | const url = urlOption(link); 13 | if (!url) { 14 | return null; 15 | } 16 | 17 | const pathParts = url.pathname.split('/').slice(2); 18 | if (!pathParts.length) { 19 | return null; 20 | } 21 | 22 | return { 23 | docsAnchor: url.hash, 24 | githubUrl: `https://raw.githubusercontent.com/discord/discord-api-docs/main/${pathParts 25 | .slice(0, -1) 26 | .join('/')}/${toMdFilename(pathParts.at(-1)!)}.md`, 27 | }; 28 | } 29 | 30 | type Heading = { 31 | docs_anchor: string; 32 | label: string; 33 | route: string; 34 | verb: string; 35 | }; 36 | 37 | function parseHeadline(text: string): Heading | null { 38 | const match = /#{1,7} (?