├── .github └── workflows │ ├── deno.yml │ └── release.yml ├── .gitignore ├── .hooks └── pre-commit ├── .lintstagedrc.json ├── .vscode ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── deno.jsonc ├── package-lock.json ├── package.json ├── src ├── README.md ├── command-group.ts ├── command.ts ├── context.ts ├── deps.deno.ts ├── deps.node.ts ├── language-codes.ts ├── mod.ts ├── types.ts └── utils │ ├── array.ts │ ├── checks.ts │ ├── errors.ts │ ├── jaro-winkler.ts │ └── set-bot-commands.ts ├── test ├── command-group.test.ts ├── command.test.ts ├── context.test.ts ├── deps.test.ts ├── integration.test.ts ├── jaroWrinkler.test.ts ├── not-found.test.ts ├── set-bot-commands.test.ts └── utils-test.test.ts └── tsconfig.json /.github/workflows/deno.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - next 8 | pull_request: 9 | branches: 10 | - main 11 | - next 12 | 13 | jobs: 14 | backport: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | 21 | - uses: denoland/setup-deno@v2 22 | with: 23 | deno-version: 2.x 24 | 25 | - run: npm install --ignore-scripts 26 | 27 | - run: npm run backport 28 | 29 | fmt-lint: 30 | runs-on: ubuntu-latest 31 | steps: 32 | - uses: actions/checkout@v4 33 | with: 34 | fetch-depth: 0 35 | 36 | - uses: denoland/setup-deno@v2 37 | with: 38 | deno-version: 2.x 39 | 40 | - run: deno fmt --check 41 | 42 | - run: deno lint 43 | 44 | test: 45 | runs-on: ${{ matrix.os }} # runs a test on Ubuntu, Windows and macOS 46 | 47 | strategy: 48 | matrix: 49 | os: [macOS-latest, windows-latest, ubuntu-latest] 50 | 51 | steps: 52 | - uses: actions/checkout@v4 53 | with: 54 | fetch-depth: 0 55 | 56 | - uses: denoland/setup-deno@v2 57 | with: 58 | deno-version: 2.x 59 | 60 | - run: deno cache -I src/mod.ts 61 | 62 | - run: deno task test 63 | 64 | coverage: 65 | runs-on: ubuntu-latest 66 | steps: 67 | - uses: actions/checkout@v4 68 | with: 69 | fetch-depth: 0 70 | 71 | - uses: denoland/setup-deno@v2 72 | with: 73 | deno-version: 2.x 74 | 75 | - run: deno task coverage 76 | 77 | - uses: codecov/codecov-action@v1.0.10 # upload the report on Codecov 78 | with: 79 | file: ./coverage.lcov 80 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "**" 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | with: 14 | fetch-depth: 0 15 | 16 | - uses: denoland/setup-deno@v2 17 | with: 18 | deno-version: 2.x 19 | 20 | - run: npm install 21 | 22 | - name: Publish to npm 23 | run: | 24 | npm config set //registry.npmjs.org/:_authToken '${NPM_TOKEN}' 25 | npm publish --ignore-scripts 26 | env: 27 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 28 | 29 | - name: Create Release 30 | uses: softprops/action-gh-release@v1 31 | env: 32 | HOOK: 0 33 | with: 34 | generate_release_notes: true 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | out 3 | usage.ts 4 | test/cov_profile 5 | coverage.lcov 6 | .vscode/launch.json 7 | -------------------------------------------------------------------------------- /.hooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/hook.sh" 3 | 4 | deno run -A npm:lint-staged 5 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*": "deno fmt" 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "request": "launch", 9 | "name": "Launch Program", 10 | "type": "node", 11 | "program": "${file}", 12 | "cwd": "${workspaceFolder}", 13 | "runtimeExecutable": "/home/roziscoding/.asdf/shims/deno", 14 | "runtimeArgs": [ 15 | "test", 16 | "--unstable", 17 | "--config", 18 | "./deno.jsonc", 19 | "--inspect-wait", 20 | "--allow-all" 21 | ], 22 | "attachSimplePort": 9229 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.unstable": true, 4 | "deno.config": "./deno.jsonc", 5 | "editor.defaultFormatter": "denoland.vscode-deno", 6 | "[typescript]": { 7 | "editor.defaultFormatter": "denoland.vscode-deno" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Rogerio Munhoz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # grammY commands 2 | 3 | This plugin provides a convenient way to define and manage commands for your grammY bot. 4 | It simplifies the process of setting up commands with scopes and localization. 5 | 6 | ## Installation 7 | 8 | ```sh 9 | npm i @grammyjs/commands 10 | ``` 11 | 12 | ## Usage 13 | 14 | The main functionality of this plugin is to define your commands, localize them, and give them handlers for each 15 | [scope](https://core.telegram.org/bots/api#botcommandscope), like so: 16 | 17 | ```ts 18 | import { Bot } from "grammy"; 19 | import { CommandGroup } from "@grammyjs/commands"; 20 | 21 | const bot = new Bot(""); 22 | 23 | const myCommands = new CommandGroup(); 24 | 25 | myCommands.command("start", "Initializes bot configuration") 26 | .localize("pt", "start", "Inicializa as configurações do bot") 27 | .addToScope( 28 | { type: "all_private_chats" }, 29 | (ctx) => ctx.reply(`Hello, ${ctx.chat.first_name}!`), 30 | ) 31 | .addToScope( 32 | { type: "all_group_chats" }, 33 | (ctx) => ctx.reply(`Hello, members of ${ctx.chat.title}!`), 34 | ); 35 | 36 | // Calls `setMyCommands` 37 | await myCommands.setCommands(bot); 38 | 39 | // Registers the command handlers 40 | bot.use(myCommands); 41 | 42 | bot.start(); 43 | ``` 44 | 45 | It is very important that you call `bot.use` with your instance of the `Commands` class. Otherwise, the command handlers 46 | will not be registered, and your bot will not respond to those commands. 47 | 48 | ### Context shortcuts 49 | 50 | This plugin provides a shortcut for setting the commands for the current chat. To use it, you need to install the 51 | commands flavor and the plugin itself, like so: 52 | 53 | ```ts 54 | import { Bot, Context } from "grammy"; 55 | import { CommandGroup, commands, CommandsFlavor } from "@grammyjs/commands"; 56 | 57 | type BotContext = CommandsFlavor; 58 | 59 | const bot = new Bot(""); 60 | bot.use(commands()); 61 | 62 | bot.on("message", async (ctx) => { 63 | const cmds = new CommandGroup(); 64 | 65 | cmds.command("start", "Initializes bot configuration") 66 | .localize("pt", "start", "Inicializa as configurações do bot"); 67 | 68 | await ctx.setMyCommands(cmds); 69 | 70 | return ctx.reply("Commands set!"); 71 | }); 72 | 73 | bot.start(); 74 | ``` 75 | -------------------------------------------------------------------------------- /deno.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "fmt": { 3 | "indentWidth": 2, 4 | "exclude": [ 5 | "node_modules", 6 | "test/cov_profile", 7 | ".vscode", 8 | "coverage.lcov", 9 | "out" 10 | ], 11 | "proseWrap": "preserve" 12 | }, 13 | "lint": { 14 | "include": ["src"], 15 | "rules": { 16 | "tags": ["recommended"] 17 | } 18 | }, 19 | "lock": false, 20 | "tasks": { 21 | "backport": "deno run --no-prompt --allow-read=. --allow-write=. https://deno.land/x/deno2node@v1.14.0/src/cli.ts", 22 | "check": "deno lint && deno fmt --check && deno check --allow-import src/mod.ts", 23 | "fix": "deno lint --fix && deno fmt", 24 | "test": "deno test --allow-import --seed=123456 --parallel ./test/", 25 | "coverage": "rm -rf ./test/cov_profile && deno task test --coverage=./test/cov_profile && deno coverage --lcov --output=./coverage.lcov ./test/cov_profile", 26 | "hook": "deno run --allow-read --allow-run --allow-write https://deno.land/x/deno_hooks@0.1.1/mod.ts" 27 | }, 28 | "exclude": [ 29 | "node_modules", 30 | "test/cov_profile", 31 | ".vscode", 32 | "coverage.lcov", 33 | "out", 34 | "src/deps.node.ts" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@grammyjs/commands", 3 | "version": "1.0.8", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "@grammyjs/commands", 9 | "version": "1.0.8", 10 | "license": "MIT", 11 | "devDependencies": { 12 | "deno2node": "^1.14.0", 13 | "typescript": "^5.6.3" 14 | }, 15 | "peerDependencies": { 16 | "grammy": "^1.17.1" 17 | } 18 | }, 19 | "node_modules/@grammyjs/types": { 20 | "version": "3.15.0", 21 | "resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-3.15.0.tgz", 22 | "integrity": "sha512-XQHJNlF6AuJgVPYL8mEWrYk4Pg/FoP9PryTVSObtf/SWRIk0dCBkIwSaGGoXa1VDrLVWl/dIP79FgS2acJJjdg==", 23 | "license": "MIT", 24 | "peer": true 25 | }, 26 | "node_modules/@ts-morph/common": { 27 | "version": "0.25.0", 28 | "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.25.0.tgz", 29 | "integrity": "sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg==", 30 | "dev": true, 31 | "dependencies": { 32 | "minimatch": "^9.0.4", 33 | "path-browserify": "^1.0.1", 34 | "tinyglobby": "^0.2.9" 35 | } 36 | }, 37 | "node_modules/abort-controller": { 38 | "version": "3.0.0", 39 | "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", 40 | "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", 41 | "license": "MIT", 42 | "peer": true, 43 | "dependencies": { 44 | "event-target-shim": "^5.0.0" 45 | }, 46 | "engines": { 47 | "node": ">=6.5" 48 | } 49 | }, 50 | "node_modules/balanced-match": { 51 | "version": "1.0.2", 52 | "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", 53 | "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", 54 | "dev": true 55 | }, 56 | "node_modules/brace-expansion": { 57 | "version": "2.0.1", 58 | "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", 59 | "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", 60 | "dev": true, 61 | "dependencies": { 62 | "balanced-match": "^1.0.0" 63 | } 64 | }, 65 | "node_modules/code-block-writer": { 66 | "version": "13.0.3", 67 | "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", 68 | "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", 69 | "dev": true 70 | }, 71 | "node_modules/debug": { 72 | "version": "4.3.7", 73 | "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", 74 | "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", 75 | "license": "MIT", 76 | "peer": true, 77 | "dependencies": { 78 | "ms": "^2.1.3" 79 | }, 80 | "engines": { 81 | "node": ">=6.0" 82 | }, 83 | "peerDependenciesMeta": { 84 | "supports-color": { 85 | "optional": true 86 | } 87 | } 88 | }, 89 | "node_modules/deno2node": { 90 | "version": "1.14.0", 91 | "resolved": "https://registry.npmjs.org/deno2node/-/deno2node-1.14.0.tgz", 92 | "integrity": "sha512-F1WpKb2OuIcMmev6tGFMzoQCJcsPTmZFymzP5mcq4Ac8xQwT4brWJqDL+LyKP9uqOPSAKU5QJi6ZIJPs4IKtSA==", 93 | "dev": true, 94 | "dependencies": { 95 | "ts-morph": "^24.0.0" 96 | }, 97 | "bin": { 98 | "deno2node": "lib/cli.js" 99 | }, 100 | "engines": { 101 | "node": ">=14.13.1" 102 | } 103 | }, 104 | "node_modules/event-target-shim": { 105 | "version": "5.0.1", 106 | "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", 107 | "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", 108 | "license": "MIT", 109 | "peer": true, 110 | "engines": { 111 | "node": ">=6" 112 | } 113 | }, 114 | "node_modules/fdir": { 115 | "version": "6.4.2", 116 | "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.2.tgz", 117 | "integrity": "sha512-KnhMXsKSPZlAhp7+IjUkRZKPb4fUyccpDrdFXbi4QL1qkmFh9kVY09Yox+n4MaOb3lHZ1Tv829C3oaaXoMYPDQ==", 118 | "dev": true, 119 | "peerDependencies": { 120 | "picomatch": "^3 || ^4" 121 | }, 122 | "peerDependenciesMeta": { 123 | "picomatch": { 124 | "optional": true 125 | } 126 | } 127 | }, 128 | "node_modules/grammy": { 129 | "version": "1.31.3", 130 | "resolved": "https://registry.npmjs.org/grammy/-/grammy-1.31.3.tgz", 131 | "integrity": "sha512-o1OlFzbTt4mvQVCTpcpdedAYrS5DOeal9IBf3FJ7l0A6FbLE9zuPuXa4XFiPvYCmRMVR7f+qcoTZyOpeYg9Xvw==", 132 | "license": "MIT", 133 | "peer": true, 134 | "dependencies": { 135 | "@grammyjs/types": "3.15.0", 136 | "abort-controller": "^3.0.0", 137 | "debug": "^4.3.4", 138 | "node-fetch": "^2.7.0" 139 | }, 140 | "engines": { 141 | "node": "^12.20.0 || >=14.13.1" 142 | } 143 | }, 144 | "node_modules/minimatch": { 145 | "version": "9.0.5", 146 | "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", 147 | "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", 148 | "dev": true, 149 | "dependencies": { 150 | "brace-expansion": "^2.0.1" 151 | }, 152 | "engines": { 153 | "node": ">=16 || 14 >=14.17" 154 | }, 155 | "funding": { 156 | "url": "https://github.com/sponsors/isaacs" 157 | } 158 | }, 159 | "node_modules/ms": { 160 | "version": "2.1.3", 161 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", 162 | "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", 163 | "license": "MIT", 164 | "peer": true 165 | }, 166 | "node_modules/node-fetch": { 167 | "version": "2.7.0", 168 | "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", 169 | "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", 170 | "license": "MIT", 171 | "peer": true, 172 | "dependencies": { 173 | "whatwg-url": "^5.0.0" 174 | }, 175 | "engines": { 176 | "node": "4.x || >=6.0.0" 177 | }, 178 | "peerDependencies": { 179 | "encoding": "^0.1.0" 180 | }, 181 | "peerDependenciesMeta": { 182 | "encoding": { 183 | "optional": true 184 | } 185 | } 186 | }, 187 | "node_modules/path-browserify": { 188 | "version": "1.0.1", 189 | "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", 190 | "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", 191 | "dev": true 192 | }, 193 | "node_modules/picomatch": { 194 | "version": "4.0.2", 195 | "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", 196 | "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", 197 | "dev": true, 198 | "engines": { 199 | "node": ">=12" 200 | }, 201 | "funding": { 202 | "url": "https://github.com/sponsors/jonschlinkert" 203 | } 204 | }, 205 | "node_modules/tinyglobby": { 206 | "version": "0.2.10", 207 | "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.10.tgz", 208 | "integrity": "sha512-Zc+8eJlFMvgatPZTl6A9L/yht8QqdmUNtURHaKZLmKBE12hNPSrqNkUp2cs3M/UKmNVVAMFQYSjYIVHDjW5zew==", 209 | "dev": true, 210 | "dependencies": { 211 | "fdir": "^6.4.2", 212 | "picomatch": "^4.0.2" 213 | }, 214 | "engines": { 215 | "node": ">=12.0.0" 216 | } 217 | }, 218 | "node_modules/tr46": { 219 | "version": "0.0.3", 220 | "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", 221 | "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", 222 | "license": "MIT", 223 | "peer": true 224 | }, 225 | "node_modules/ts-morph": { 226 | "version": "24.0.0", 227 | "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-24.0.0.tgz", 228 | "integrity": "sha512-2OAOg/Ob5yx9Et7ZX4CvTCc0UFoZHwLEJ+dpDPSUi5TgwwlTlX47w+iFRrEwzUZwYACjq83cgjS/Da50Ga37uw==", 229 | "dev": true, 230 | "dependencies": { 231 | "@ts-morph/common": "~0.25.0", 232 | "code-block-writer": "^13.0.3" 233 | } 234 | }, 235 | "node_modules/typescript": { 236 | "version": "5.6.3", 237 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", 238 | "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", 239 | "dev": true, 240 | "license": "Apache-2.0", 241 | "bin": { 242 | "tsc": "bin/tsc", 243 | "tsserver": "bin/tsserver" 244 | }, 245 | "engines": { 246 | "node": ">=14.17" 247 | } 248 | }, 249 | "node_modules/webidl-conversions": { 250 | "version": "3.0.1", 251 | "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", 252 | "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", 253 | "license": "BSD-2-Clause", 254 | "peer": true 255 | }, 256 | "node_modules/whatwg-url": { 257 | "version": "5.0.0", 258 | "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", 259 | "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", 260 | "license": "MIT", 261 | "peer": true, 262 | "dependencies": { 263 | "tr46": "~0.0.3", 264 | "webidl-conversions": "^3.0.0" 265 | } 266 | } 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@grammyjs/commands", 3 | "version": "1.0.8", 4 | "description": "grammY Commands Plugin", 5 | "main": "out/mod.js", 6 | "scripts": { 7 | "backport": "deno2node tsconfig.json", 8 | "prepare": "npm run backport" 9 | }, 10 | "keywords": [ 11 | "grammY", 12 | "telegram", 13 | "bot", 14 | "commands" 15 | ], 16 | "author": "Roz ", 17 | "license": "MIT", 18 | "homepage": "https://grammy.dev/plugins/commands", 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/grammyjs/commands.git" 22 | }, 23 | "bugs": { 24 | "url": "https://github.com/grammyjs/commands/issues" 25 | }, 26 | "peerDependencies": { 27 | "grammy": "^1.17.1" 28 | }, 29 | "devDependencies": { 30 | "deno2node": "^1.14.0", 31 | "typescript": "^5.6.3" 32 | }, 33 | "files": [ 34 | "out" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /src/README.md: -------------------------------------------------------------------------------- 1 | # grammY commands 2 | 3 | This commands plugin for [grammY](https://grammy.dev) provides a convenient way to define and manage commands for your grammY bot. 4 | It simplifies the process of setting up commands with scopes and localization. 5 | Please confer the [official documentation](https://grammy.dev/plugins/commands) for this plugin to learn more about it. 6 | 7 | Here is a quickstart to get you up and running, though. 8 | 9 | ## Quickstart 10 | 11 | You can define bot commands using the `CommandGroup` class. 12 | Remember to register it on your bot via `bot.use`. 13 | 14 | Finally, this plugin can call `setMyCommands` for you with the commands you defined. 15 | That way, your users see the correct command suggestions in chats with your bot. 16 | 17 | ```ts 18 | import { Bot } from "https://deno.land/x/grammy/mod.ts"; 19 | import { CommandGroup } from "https://deno.land/x/grammy_commands/mod.ts"; 20 | 21 | const bot = new Bot(""); 22 | 23 | const myCommands = new CommandGroup(); 24 | 25 | myCommands.command("start", "Initializes bot configuration") 26 | .localize("pt", "start", "Inicializa as configurações do bot") 27 | .addToScope( 28 | { type: "all_private_chats" }, 29 | (ctx) => ctx.reply(`Hello, ${ctx.chat.first_name}!`), 30 | ) 31 | .addToScope( 32 | { type: "all_group_chats" }, 33 | (ctx) => ctx.reply(`Hello, members of ${ctx.chat.title}!`), 34 | ); 35 | 36 | // Calls `setMyCommands` 37 | await myCommands.setCommands(bot); 38 | 39 | // Registers the command handlers 40 | bot.use(myCommands); 41 | 42 | bot.start(); 43 | ``` 44 | 45 | Be sure to check out [the documentation](https://grammy.dev/plugins/commands). 46 | -------------------------------------------------------------------------------- /src/command-group.ts: -------------------------------------------------------------------------------- 1 | import { Command, CommandsFlavor } from "./mod.ts"; 2 | import { 3 | Api, 4 | BotCommand, 5 | BotCommandScope, 6 | CommandContext, 7 | Composer, 8 | Context, 9 | type LanguageCode, 10 | Middleware, 11 | } from "./deps.deno.ts"; 12 | import type { BotCommandX, CommandOptions } from "./types.ts"; 13 | import { 14 | ensureArray, 15 | getCommandsRegex, 16 | type MaybeArray, 17 | } from "./utils/array.ts"; 18 | import { 19 | setBotCommands, 20 | SetBotCommandsOptions, 21 | } from "./utils/set-bot-commands.ts"; 22 | import { JaroWinklerOptions } from "./utils/jaro-winkler.ts"; 23 | import { isCommandOptions, isMiddleware } from "./utils/checks.ts"; 24 | 25 | /** 26 | * Interface for grouping {@link BotCommand}s that might (or not) 27 | * be related to each other by scope and/or language. 28 | */ 29 | export interface SetMyCommandsParams { 30 | /** If defined: scope on which the commands will take effect */ 31 | scope?: BotCommandScope; 32 | /** If defined: Language on which the commands will take effect. 33 | * Two letter abbreviation in ISO_639 standard: https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes 34 | */ 35 | language_code?: LanguageCode; 36 | /** Commands that can be each one passed to a SetMyCommands Call */ 37 | commands: (BotCommand & { hasHandler?: boolean })[]; 38 | } 39 | 40 | /** 41 | * Interface to represent uncompliance of a command 42 | * with the Bot API 43 | */ 44 | export interface UncompliantCommand { 45 | /** Name of the uncompliant command */ 46 | name: string; 47 | /** Reason why the command was considered uncompliant */ 48 | reasons: string[]; 49 | /** Language in which the command is uncompliant */ 50 | language: LanguageCode | "default"; 51 | } 52 | 53 | /** 54 | * Central class that manages all registered commands. 55 | * This is the starting point for the plugin, and this is what you should pass to `bot.use` so your commands get properly registered. 56 | * 57 | * @example 58 | * ```typescript 59 | * const myCommands = new CommandGroup() 60 | * commands.command("start", "start the bot configuration", (ctx) => ctx.reply("Hello there!")) 61 | * 62 | * // Registers the commands with the bot instance. 63 | * bot.use(myCommands) 64 | * ``` 65 | */ 66 | export class CommandGroup { 67 | private _languages: Set = new Set(); 68 | private _scopes: Map>> = new Map(); 69 | private _commands: Command[] = []; 70 | 71 | private _cachedComposer: Composer = new Composer(); 72 | private _cachedComposerInvalidated: boolean = false; 73 | 74 | private _commandOptions: Partial = {}; 75 | 76 | constructor(options: Partial = {}) { 77 | this._commandOptions = options; 78 | if (this._commandOptions.prefix?.trim() === "") { 79 | this._commandOptions.prefix = "/"; 80 | } 81 | } 82 | 83 | private _addCommandToScope(scope: BotCommandScope, command: Command) { 84 | const commands = this._scopes.get(JSON.stringify(scope)) ?? []; 85 | this._scopes.set(JSON.stringify(scope), commands.concat([command])); 86 | } 87 | 88 | private _populateMetadata() { 89 | this._languages.clear(); 90 | this._scopes.clear(); 91 | 92 | this._commands.forEach((command) => { 93 | for (const scope of command.scopes) { 94 | this._addCommandToScope(scope, command); 95 | } 96 | 97 | for (const language of command.languages.keys()) { 98 | this._languages.add(language); 99 | } 100 | }); 101 | } 102 | 103 | /** 104 | * Registers a new command with a default handler. 105 | * @param name Default command name 106 | * @param description Default command description 107 | * @param handler Default command handler 108 | * @param options Extra options that should apply only to this command 109 | * @returns An instance of the `Command` class 110 | */ 111 | public command( 112 | name: string | RegExp, 113 | description: string, 114 | handler: MaybeArray>>, 115 | options?: Partial, 116 | ): Command; 117 | /** 118 | * Registers a new command with no handlers. 119 | * @param name Default command name 120 | * @param description Default command description 121 | * @param options Extra options that should apply only to this command 122 | * @returns An instance of the `Command` class 123 | */ 124 | public command( 125 | name: string | RegExp, 126 | description: string, 127 | options?: Partial, 128 | ): Command; 129 | public command( 130 | name: string | RegExp, 131 | description: string, 132 | handlerOrOptions?: 133 | | MaybeArray>> 134 | | Partial, 135 | _options?: Partial, 136 | ) { 137 | const handler = isMiddleware(handlerOrOptions) 138 | ? handlerOrOptions 139 | : undefined; 140 | 141 | const options = !handler && isCommandOptions(handlerOrOptions) 142 | ? { ...this._commandOptions, ...handlerOrOptions } 143 | : { ...this._commandOptions, ..._options }; 144 | 145 | const command = new Command(name, description, handler, options); 146 | 147 | this._commands.push(command); 148 | this._cachedComposerInvalidated = true; 149 | 150 | return command; 151 | } 152 | 153 | /** 154 | * Registers a Command that was created by it's own. 155 | * 156 | * @param command the command or list of commands being added to the group 157 | */ 158 | public add(command: Command | Command[]) { 159 | this._commands.push(...ensureArray(command)); 160 | this._cachedComposerInvalidated = true; 161 | return this; 162 | } 163 | 164 | /** 165 | * Serializes the commands into multiple objects that can each be passed to a `setMyCommands` call. 166 | * 167 | * @returns One item for each combination of command + scope + language 168 | */ 169 | public toArgs() { 170 | this._populateMetadata(); 171 | const scopes: SetMyCommandsParams[] = []; 172 | const uncompliantCommands: UncompliantCommand[] = []; 173 | 174 | for (const [scope, commands] of this._scopes.entries()) { 175 | for (const language of this._languages) { 176 | const compliantScopedCommands: Command[] = []; 177 | 178 | commands.forEach((command) => { 179 | const [isApiCompliant, ...reasons] = command.isApiCompliant( 180 | language, 181 | ); 182 | 183 | if (isApiCompliant) { 184 | return compliantScopedCommands.push(command); 185 | } 186 | 187 | uncompliantCommands.push({ 188 | name: command.stringName, 189 | reasons: reasons, 190 | language, 191 | }); 192 | }); 193 | 194 | if (compliantScopedCommands.length) { 195 | scopes.push({ 196 | scope: JSON.parse(scope), 197 | language_code: language === "default" ? undefined : language, 198 | commands: compliantScopedCommands.map((command) => 199 | command.toObject(language) 200 | ), 201 | }); 202 | } 203 | } 204 | } 205 | 206 | return { 207 | scopes, 208 | uncompliantCommands, 209 | }; 210 | } 211 | 212 | /** 213 | * Serializes the commands of a single scope into objects that can each be passed to a `setMyCommands` call. 214 | * 215 | * @param scope Selected scope to be serialized 216 | * @returns One item per command per language 217 | */ 218 | public toSingleScopeArgs( 219 | scope: BotCommandScope, 220 | ) { 221 | this._populateMetadata(); 222 | 223 | const commandParams: SetMyCommandsParams[] = []; 224 | 225 | const uncompliantCommands: UncompliantCommand[] = []; 226 | for (const language of this._languages) { 227 | const compliantCommands: Command[] = []; 228 | 229 | this._commands.forEach((command) => { 230 | const [isApiCompliant, ...reasons] = command.isApiCompliant( 231 | language, 232 | ); 233 | 234 | if (!isApiCompliant) { 235 | return uncompliantCommands.push({ 236 | name: command.stringName, 237 | reasons: reasons, 238 | language, 239 | }); 240 | } 241 | 242 | if (command.scopes.length) compliantCommands.push(command); 243 | }); 244 | 245 | commandParams.push({ 246 | scope, 247 | language_code: language === "default" ? undefined : language, 248 | commands: compliantCommands.map((command) => 249 | command.toObject(language) 250 | ), 251 | }); 252 | } 253 | 254 | return { commandParams, uncompliantCommands }; 255 | } 256 | 257 | /** 258 | * Registers all commands to be displayed by clients according to their scopes and languages 259 | * Calls `setMyCommands` for each language of each scope of each command. 260 | * 261 | * [!IMPORTANT] 262 | * Calling this method with upperCased command names registered, will throw 263 | * @see https://core.telegram.org/bots/api#botcommand 264 | * @see https://core.telegram.org/method/bots.setBotCommands 265 | * 266 | * @param Instance of `bot` or { api: bot.api } 267 | */ 268 | public async setCommands( 269 | { api }: { api: Api }, 270 | options?: Partial, 271 | ) { 272 | const { scopes, uncompliantCommands } = this.toArgs(); 273 | 274 | await setBotCommands(api, scopes, uncompliantCommands, options); 275 | } 276 | 277 | /** 278 | * Serialize all register commands into a more detailed object 279 | * including it's name, prefix and language, and more data 280 | * 281 | * @param filterLanguage if undefined, it returns all names 282 | * else get only the locales for the given filterLanguage 283 | * fallbacks to "default" 284 | * 285 | * @returns an array of {@link BotCommandX} 286 | * 287 | * Note: mainly used to serialize for {@link FuzzyMatch} 288 | */ 289 | 290 | public toElementals( 291 | filterLanguage?: LanguageCode | "default", 292 | ): BotCommandX[] { 293 | this._populateMetadata(); 294 | 295 | return Array.from(this._scopes.values()) 296 | .flat() 297 | .flatMap( 298 | (command) => { 299 | const elements = []; 300 | for ( 301 | const [language, local] of command.languages.entries() 302 | ) { 303 | elements.push({ 304 | command: local.name instanceof RegExp 305 | ? local.name.source 306 | : local.name, 307 | language, 308 | prefix: command.prefix, 309 | scopes: command.scopes, 310 | description: command.getLocalizedDescription( 311 | language, 312 | ), 313 | ...(command.hasHandler 314 | ? { hasHandler: true } 315 | : { hasHandler: false }), 316 | }); 317 | } 318 | if (filterLanguage) { 319 | const filtered = elements.filter((command) => 320 | command.language === filterLanguage 321 | ); 322 | const defaulted = elements.filter((command) => 323 | command.language === "default" 324 | ); 325 | return filtered.length ? filtered[0] : defaulted[0]; 326 | } else return elements; 327 | }, 328 | ); 329 | } 330 | 331 | /** 332 | * @returns A JSON serialized version of all the currently registered commands 333 | */ 334 | public toString() { 335 | return JSON.stringify(this); 336 | } 337 | 338 | middleware() { 339 | if (this._cachedComposerInvalidated) { 340 | this._cachedComposer = new Composer(...this._commands); 341 | this._cachedComposerInvalidated = false; 342 | } 343 | return this._cachedComposer.middleware(); 344 | } 345 | 346 | /** 347 | * @returns all {@link Command}s contained in the instance 348 | */ 349 | public get commands(): Command[] { 350 | return this._commands; 351 | } 352 | 353 | /** 354 | * @returns all prefixes registered in this instance 355 | */ 356 | public get prefixes(): string[] { 357 | return [ 358 | ...new Set(this._commands.flatMap((command) => command.prefix)), 359 | ]; 360 | } 361 | 362 | /** 363 | * Replaces the `toString` method on Deno 364 | * 365 | * @see toString 366 | */ 367 | [Symbol.for("Deno.customInspect")]() { 368 | return this.toString(); 369 | } 370 | 371 | /** 372 | * Replaces the `toString` method on Node.js 373 | * 374 | * @see toString 375 | */ 376 | [Symbol.for("nodejs.util.inspect.custom")]() { 377 | return this.toString(); 378 | } 379 | } 380 | 381 | type HaveCommandLike< 382 | C extends Context = Context, 383 | CF extends CommandsFlavor = CommandsFlavor, 384 | > = C & CF & { 385 | commandSuggestion: string | null; 386 | }; 387 | 388 | export function commandNotFound< 389 | CF extends CommandsFlavor, 390 | C extends Context = Context, 391 | >( 392 | commands: CommandGroup | CommandGroup[], 393 | opts: Omit, "language"> = {}, 394 | ) { 395 | return function ( 396 | ctx: C, 397 | ): ctx is HaveCommandLike { 398 | if (containsCommands(ctx, commands)) { 399 | (ctx as HaveCommandLike) 400 | .commandSuggestion = (ctx as HaveCommandLike) 401 | .getNearestCommand(commands, opts); 402 | return true; 403 | } 404 | return false; 405 | }; 406 | } 407 | 408 | function containsCommands< 409 | C extends Context, 410 | >( 411 | ctx: C, 412 | commands: CommandGroup | CommandGroup[], 413 | ) { 414 | let allPrefixes = [ 415 | ...new Set( 416 | ensureArray(commands).flatMap((cmds) => cmds.prefixes), 417 | ), 418 | ]; 419 | if (allPrefixes.length < 1) { 420 | allPrefixes = ["/"]; 421 | } 422 | 423 | for (const prefix of allPrefixes) { 424 | const regex = getCommandsRegex(prefix); 425 | if (ctx.hasText(regex)) return true; 426 | } 427 | return false; 428 | } 429 | -------------------------------------------------------------------------------- /src/command.ts: -------------------------------------------------------------------------------- 1 | import { CommandsFlavor } from "./context.ts"; 2 | import { 3 | type BotCommandScope, 4 | type BotCommandScopeAllChatAdministrators, 5 | type BotCommandScopeAllGroupChats, 6 | type BotCommandScopeAllPrivateChats, 7 | type ChatTypeMiddleware, 8 | CommandContext, 9 | Composer, 10 | type Context, 11 | type LanguageCode, 12 | type Middleware, 13 | type MiddlewareObj, 14 | type NextFunction, 15 | } from "./deps.deno.ts"; 16 | import type { BotCommandX, CommandOptions } from "./types.ts"; 17 | import { ensureArray, type MaybeArray } from "./utils/array.ts"; 18 | import { 19 | isAdmin, 20 | isCommandOptions, 21 | isMiddleware, 22 | matchesPattern, 23 | } from "./utils/checks.ts"; 24 | import { InvalidScopeError } from "./utils/errors.ts"; 25 | 26 | type BotCommandGroupsScope = 27 | | BotCommandScopeAllGroupChats 28 | | BotCommandScopeAllChatAdministrators; 29 | 30 | type ScopeHandlerTuple = [ 31 | CommandOptions, 32 | Middleware[] | undefined, 33 | ]; 34 | 35 | /** 36 | * Represents a matched command, the result of the RegExp match, and the rest of the input. 37 | */ 38 | export interface CommandMatch { 39 | /** 40 | * The matched command. 41 | */ 42 | command: string | RegExp; 43 | /** 44 | * The rest of the input after the command. 45 | */ 46 | rest: string; 47 | /** 48 | * The result of the RegExp match. 49 | * 50 | * Only defined if the command is a RegExp. 51 | */ 52 | match?: RegExpExecArray | null; 53 | } 54 | 55 | const NOCASE_COMMAND_NAME_REGEX = /^[0-9a-z_]+$/i; 56 | 57 | /** 58 | * Class that represents a single command and allows you to configure it. 59 | */ 60 | export class Command implements MiddlewareObj { 61 | private _scopes: BotCommandScope[] = []; 62 | private _languages: Map< 63 | LanguageCode | "default", 64 | { name: string | RegExp; description: string } 65 | > = new Map(); 66 | private _defaultScopeComposer = new Composer(); 67 | private _options: CommandOptions = { 68 | prefix: "/", 69 | matchOnlyAtStart: true, 70 | targetedCommands: "optional", 71 | ignoreCase: false, 72 | }; 73 | 74 | private _scopeHandlers = new Map< 75 | BotCommandScope, 76 | ScopeHandlerTuple 77 | >(); 78 | private _cachedComposer: Composer = new Composer(); 79 | private _cachedComposerInvalidated: boolean = false; 80 | private _hasHandler: boolean; 81 | 82 | /** 83 | * Initialize a new command with a default handler. 84 | * 85 | * [!IMPORTANT] This class by its own does nothing. It needs to be imported into a `CommandGroup` 86 | * via the `add` method. 87 | * 88 | * @example 89 | * ```ts 90 | * const sayHi = new Command("hi","say hi", (ctx) => ctx.reply("hi")) 91 | * const myCmds = new CommandGroup().add(sayHi) 92 | * ``` 93 | * 94 | * @param name Default command name 95 | * @param description Default command description 96 | * @param handler Default command handler 97 | * @param options Extra options that should apply only to this command 98 | * @returns An instance of the `Command` class 99 | */ 100 | 101 | constructor( 102 | name: string | RegExp, 103 | description: string, 104 | handler: MaybeArray>>, 105 | options?: Partial, 106 | ); 107 | /** 108 | * Initialize a new command with no handlers. 109 | * 110 | * [!IMPORTANT] This class by its own does nothing. It needs to be imported into a `CommandGroup` 111 | * via the `add` method 112 | * 113 | * @example 114 | * ```ts 115 | * const sayHi = new Command("hi","say hi", (ctx) => ctx.reply("hi") ) 116 | * const myCmds = new CommandGroup().add(sayHi) 117 | * ``` 118 | * 119 | * @param name Default command name 120 | * @param description Default command description 121 | * @param options Extra options that should apply only to this command 122 | * @returns An instance of the `Command` class 123 | */ 124 | constructor( 125 | name: string | RegExp, 126 | description: string, 127 | options?: Partial, 128 | ); 129 | constructor( 130 | name: string | RegExp, 131 | description: string, 132 | handlerOrOptions?: 133 | | MaybeArray>> 134 | | Partial, 135 | options?: Partial, 136 | ); 137 | constructor( 138 | name: string | RegExp, 139 | description: string, 140 | handlerOrOptions?: 141 | | MaybeArray>> 142 | | Partial, 143 | options?: Partial, 144 | ) { 145 | let handler = isMiddleware(handlerOrOptions) ? handlerOrOptions : undefined; 146 | 147 | options = !handler && isCommandOptions(handlerOrOptions) 148 | ? handlerOrOptions 149 | : options; 150 | 151 | if (!handler) { 152 | handler = async (_ctx: Context, next: NextFunction) => await next(); 153 | this._hasHandler = false; 154 | } else this._hasHandler = true; 155 | 156 | this._options = { ...this._options, ...options }; 157 | if (this._options.prefix?.trim() === "") this._options.prefix = "/"; 158 | this._languages.set("default", { name: name, description }); 159 | if (handler) { 160 | this.addToScope({ type: "default" }, handler); 161 | } 162 | return this; 163 | } 164 | 165 | /** 166 | * Whether the command has a custom prefix 167 | */ 168 | get hasCustomPrefix() { 169 | return this.prefix && this.prefix !== "/"; 170 | } 171 | 172 | /** 173 | * Gets the command name as string 174 | */ 175 | public get stringName() { 176 | return typeof this.name === "string" ? this.name : this.name.source; 177 | } 178 | 179 | /** 180 | * Whether the command can be passed to a `setMyCommands` API call 181 | * and, if not, the reason. 182 | */ 183 | public isApiCompliant( 184 | language?: LanguageCode | "default", 185 | ): [result: true] | [ 186 | result: false, 187 | ...reasons: string[], 188 | ] { 189 | const problems: string[] = []; 190 | 191 | if (this.hasCustomPrefix) { 192 | problems.push(`Command has custom prefix: ${this._options.prefix}`); 193 | } 194 | 195 | const name = language ? this.getLocalizedName(language) : this.name; 196 | 197 | if (typeof name !== "string") { 198 | problems.push("Command has a regular expression name"); 199 | } 200 | 201 | if (typeof name === "string") { 202 | if (name.toLowerCase() !== name) { 203 | problems.push("Command name has uppercase characters"); 204 | } 205 | 206 | if (name.length > 32) { 207 | problems.push( 208 | `Command name is too long (${name.length} characters). Maximum allowed is 32 characters`, 209 | ); 210 | } 211 | 212 | if (!NOCASE_COMMAND_NAME_REGEX.test(name)) { 213 | problems.push( 214 | `Command name has special characters (${ 215 | name.replace(/[0-9a-z_]/ig, "") 216 | }). Only letters, digits and _ are allowed`, 217 | ); 218 | } 219 | } 220 | 221 | return problems.length ? [false, ...problems] : [true]; 222 | } 223 | 224 | /** 225 | * Get registered scopes for this command 226 | */ 227 | get scopes() { 228 | return this._scopes; 229 | } 230 | 231 | /** 232 | * Get registered languages for this command 233 | */ 234 | get languages() { 235 | return this._languages; 236 | } 237 | 238 | /** 239 | * Get registered names for this command 240 | */ 241 | get names() { 242 | return Array.from(this._languages.values()).map(({ name }) => name); 243 | } 244 | 245 | /** 246 | * Get the default name for this command 247 | */ 248 | get name() { 249 | return this._languages.get("default")!.name; 250 | } 251 | 252 | /** 253 | * Get the default description for this command 254 | */ 255 | get description() { 256 | return this._languages.get("default")!.description; 257 | } 258 | 259 | /** 260 | * Get the prefix for this command 261 | */ 262 | get prefix() { 263 | return this._options.prefix; 264 | } 265 | 266 | /** 267 | * Get if this command has a handler 268 | */ 269 | get hasHandler(): boolean { 270 | return this._hasHandler; 271 | } 272 | 273 | /** 274 | * Registers the command to a scope to allow it to be handled and used with `setMyCommands`. 275 | * This will automatically apply filtering middlewares for you, so the handler only runs on the specified scope. 276 | * 277 | * @example 278 | * ```ts 279 | * const myCommands = new CommandGroup(); 280 | * myCommands.command("start", "Initializes bot configuration") 281 | * .addToScope( 282 | * { type: "all_private_chats" }, 283 | * (ctx) => ctx.reply(`Hello, ${ctx.chat.first_name}!`), 284 | * ) 285 | * .addToScope( 286 | * { type: "all_group_chats" }, 287 | * (ctx) => ctx.reply(`Hello, members of ${ctx.chat.title}!`), 288 | * ); 289 | * ``` 290 | * 291 | * @param scope Which scope this command should be available on 292 | * @param middleware The handler for this command on the specified scope 293 | * @param options Additional options that should apply only to this scope 294 | */ 295 | public addToScope( 296 | scope: BotCommandGroupsScope, 297 | middleware?: MaybeArray>, 298 | options?: Partial, 299 | ): this; 300 | public addToScope( 301 | scope: BotCommandScopeAllPrivateChats, 302 | middleware?: MaybeArray>, 303 | options?: Partial, 304 | ): this; 305 | public addToScope( 306 | scope: BotCommandScope, 307 | middleware?: MaybeArray>, 308 | options?: Partial, 309 | ): this; 310 | public addToScope( 311 | scope: BotCommandScope, 312 | middleware?: MaybeArray>, 313 | options: Partial = this._options, 314 | ): this { 315 | const middlewareArray = middleware ? ensureArray(middleware) : undefined; 316 | const optionsObject = { ...this._options, ...options }; 317 | 318 | this._scopes.push(scope); 319 | if (middlewareArray && middlewareArray.length) { 320 | this._scopeHandlers.set(scope, [optionsObject, middlewareArray]); 321 | this._cachedComposerInvalidated = true; 322 | } 323 | 324 | return this; 325 | } 326 | 327 | /** 328 | * Finds the matching command in the given context 329 | * 330 | * @example 331 | * ```ts 332 | * // ctx.msg.text = "/delete_123 something" 333 | * const match = Command.findMatchingCommand(/delete_(.*)/, { prefix: "/", ignoreCase: true }, ctx) 334 | * // match is { command: /delete_(.*)/, rest: ["something"], match: ["delete_123"] } 335 | * ``` 336 | */ 337 | public static findMatchingCommand( 338 | command: MaybeArray, 339 | options: CommandOptions, 340 | ctx: Context, 341 | ): CommandMatch | null { 342 | const { matchOnlyAtStart, prefix, targetedCommands } = options; 343 | 344 | if (!ctx.has([":text", ":caption"])) return null; 345 | const txt = ctx.msg.text ?? ctx.msg.caption; 346 | 347 | if (matchOnlyAtStart && !txt.startsWith(prefix)) { 348 | return null; 349 | } 350 | 351 | const commandNames = ensureArray(command); 352 | const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); 353 | const commandRegex = new RegExp( 354 | `${escapedPrefix}(?[^@ ]+)(?:@(?[^\\s]*))?(?.*)`, 355 | "g", 356 | ); 357 | 358 | const firstCommand = commandRegex.exec(txt)?.groups; 359 | 360 | if (!firstCommand) return null; 361 | 362 | if (!firstCommand.username && targetedCommands === "required") return null; 363 | if (firstCommand.username && firstCommand.username !== ctx.me.username) { 364 | return null; 365 | } 366 | if (firstCommand.username && targetedCommands === "ignored") return null; 367 | 368 | const matchingCommand = commandNames.find((name) => { 369 | const matches = matchesPattern( 370 | name instanceof RegExp 371 | ? firstCommand.command + firstCommand.rest 372 | : firstCommand.command, 373 | name, 374 | options.ignoreCase, 375 | ); 376 | return matches; 377 | }); 378 | 379 | if (matchingCommand instanceof RegExp) { 380 | return { 381 | command: matchingCommand, 382 | rest: firstCommand.rest.trim(), 383 | match: matchingCommand.exec(txt), 384 | }; 385 | } 386 | 387 | if (matchingCommand) { 388 | return { 389 | command: matchingCommand, 390 | rest: firstCommand.rest.trim(), 391 | }; 392 | } 393 | 394 | return null; 395 | } 396 | 397 | /** 398 | * Creates a matcher for the given command that can be used in filtering operations 399 | * 400 | * @example 401 | * ```ts 402 | * bot 403 | * .filter( 404 | * Command.hasCommand(/delete_(.*)/), 405 | * (ctx) => ctx.reply(`Deleting ${ctx.message?.text?.split("_")[1]}`) 406 | * ) 407 | * ``` 408 | * 409 | * @param command Command name or RegEx 410 | * @param options Options that should apply to the matching algorithm 411 | * @returns A predicate that matches the given command 412 | */ 413 | public static hasCommand( 414 | command: MaybeArray, 415 | options: CommandOptions, 416 | ) { 417 | return (ctx: Context) => { 418 | const matchingCommand = Command.findMatchingCommand( 419 | command, 420 | options, 421 | ctx, 422 | ); 423 | 424 | if (!matchingCommand) return false; 425 | 426 | ctx.match = matchingCommand.rest; 427 | // TODO: Clean this up. But how to do it without requiring the user to install the commands flavor? 428 | (ctx as Context & CommandsFlavor).commandMatch = matchingCommand; 429 | 430 | return true; 431 | }; 432 | } 433 | 434 | /** 435 | * Adds a new translation for the command 436 | * 437 | * @example 438 | * ```ts 439 | * myCommands 440 | * .command("start", "Starts the bot configuration") 441 | * .localize("pt", "iniciar", "Inicia a configuração do bot") 442 | * ``` 443 | * 444 | * @param languageCode Language this translation applies to. @see https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes 445 | * @param name Localized command name 446 | * @param description Localized command description 447 | */ 448 | public localize( 449 | languageCode: LanguageCode, 450 | name: string | RegExp, 451 | description: string, 452 | ) { 453 | this._languages.set(languageCode, { 454 | name: name, 455 | description, 456 | }); 457 | this._cachedComposerInvalidated = true; 458 | return this; 459 | } 460 | 461 | /** 462 | * Gets the localized command name of an existing translation 463 | * @param languageCode Language to get the name for 464 | * @returns Localized command name 465 | */ 466 | public getLocalizedName(languageCode: LanguageCode | "default") { 467 | return this._languages.get(languageCode)?.name ?? this.name; 468 | } 469 | 470 | /** 471 | * Gets the localized command name of an existing translation 472 | * @param languageCode Language to get the name for 473 | * @returns Localized command name 474 | */ 475 | public getLocalizedDescription(languageCode: LanguageCode | "default") { 476 | return this._languages.get(languageCode)?.description ?? 477 | this.description; 478 | } 479 | 480 | /** 481 | * Converts command to an object representation. 482 | * Useful for JSON serialization. 483 | * 484 | * @param languageCode If specified, uses localized versions of the command name and description 485 | * @returns Object representation of this command 486 | */ 487 | public toObject( 488 | languageCode: LanguageCode | "default" = "default", 489 | ): Pick { 490 | const localizedName = this.getLocalizedName(languageCode); 491 | return { 492 | command: localizedName instanceof RegExp 493 | ? localizedName.source 494 | : localizedName, 495 | description: this.getLocalizedDescription(languageCode), 496 | ...(this.hasHandler ? { hasHandler: true } : { hasHandler: false }), 497 | }; 498 | } 499 | 500 | private registerScopeHandlers() { 501 | const entries = this._scopeHandlers.entries(); 502 | 503 | for (const [scope, [optionsObject, middlewareArray]] of entries) { 504 | if (middlewareArray) { 505 | switch (scope.type) { 506 | case "default": 507 | this._defaultScopeComposer 508 | .filter(Command.hasCommand(this.names, optionsObject)) 509 | .use(...middlewareArray); 510 | break; 511 | case "all_chat_administrators": 512 | this._cachedComposer 513 | .filter(Command.hasCommand(this.names, optionsObject)) 514 | .chatType(["group", "supergroup"]) 515 | .filter(isAdmin) 516 | .use(...middlewareArray); 517 | break; 518 | case "all_private_chats": 519 | this._cachedComposer 520 | .filter(Command.hasCommand(this.names, optionsObject)) 521 | .chatType("private") 522 | .use(...middlewareArray); 523 | break; 524 | case "all_group_chats": 525 | this._cachedComposer 526 | .filter(Command.hasCommand(this.names, optionsObject)) 527 | .chatType(["group", "supergroup"]) 528 | .use(...middlewareArray); 529 | break; 530 | case "chat": 531 | if (scope.chat_id) { 532 | this._cachedComposer 533 | .filter(Command.hasCommand(this.names, optionsObject)) 534 | .chatType(["group", "supergroup", "private"]) 535 | .filter((ctx) => ctx.chatId === scope.chat_id) 536 | .use(...middlewareArray); 537 | } 538 | break; 539 | case "chat_administrators": 540 | if (scope.chat_id) { 541 | this._cachedComposer 542 | .filter(Command.hasCommand(this.names, optionsObject)) 543 | .chatType(["group", "supergroup"]) 544 | .filter((ctx) => ctx.chatId === scope.chat_id) 545 | .filter(isAdmin) 546 | .use(...middlewareArray); 547 | } 548 | break; 549 | case "chat_member": 550 | if (scope.chat_id && scope.user_id) { 551 | this._cachedComposer 552 | .filter(Command.hasCommand(this.names, optionsObject)) 553 | .chatType(["group", "supergroup"]) 554 | .filter((ctx) => ctx.chatId === scope.chat_id) 555 | .filter((ctx) => ctx.from?.id === scope.user_id) 556 | .use(...middlewareArray); 557 | } 558 | break; 559 | default: 560 | throw new InvalidScopeError(scope); 561 | } 562 | } 563 | } 564 | 565 | this._cachedComposer.use(this._defaultScopeComposer); 566 | this._cachedComposerInvalidated = false; 567 | } 568 | 569 | middleware() { 570 | if (this._cachedComposerInvalidated) { 571 | this.registerScopeHandlers(); 572 | } 573 | 574 | return this._cachedComposer.middleware(); 575 | } 576 | } 577 | -------------------------------------------------------------------------------- /src/context.ts: -------------------------------------------------------------------------------- 1 | import { CommandGroup } from "./command-group.ts"; 2 | import { CommandMatch } from "./command.ts"; 3 | import { BotCommandScopeChat, Context, NextFunction } from "./deps.deno.ts"; 4 | import { SetMyCommandsParams } from "./mod.ts"; 5 | import { BotCommandEntity } from "./types.ts"; 6 | import { ensureArray, getCommandsRegex } from "./utils/array.ts"; 7 | import { fuzzyMatch, JaroWinklerOptions } from "./utils/jaro-winkler.ts"; 8 | import { 9 | setBotCommands, 10 | SetBotCommandsOptions, 11 | } from "./utils/set-bot-commands.ts"; 12 | 13 | export interface CommandsFlavor extends Context { 14 | /** 15 | * Sets the provided commands for the current chat. 16 | * Cannot be called on updates that don't have a `chat` property. 17 | * 18 | * [!IMPORTANT] 19 | * Calling this method with upperCased command names registered, will throw 20 | * @see https://core.telegram.org/bots/api#botcommand 21 | * @see https://core.telegram.org/method/bots.setBotCommands 22 | * 23 | * @example 24 | * ```typescript 25 | * bot.hears("sudo", (ctx) => 26 | * ctx.setMyCommands(userCommands, adminCommands)); 27 | * bot.hears("logout", (ctx) => 28 | * ctx.setMyCommands(userCommands)); 29 | * bot.hears("example", (ctx) => 30 | * ctx.setMyCommands([aCommands, bCommands, cCommands])); 31 | * ``` 32 | * 33 | * @param commands List of available commands 34 | * @returns Promise with the result of the operations 35 | */ 36 | setMyCommands: ( 37 | commands: CommandGroup | CommandGroup[], 38 | options?: SetBotCommandsOptions, 39 | ) => Promise; 40 | 41 | /** 42 | * Returns the nearest command to the user input. 43 | * If no command is found, returns `null`. 44 | * 45 | * @param commands List of available commands 46 | * @param options Options for the Jaro-Winkler algorithm 47 | * @returns The nearest command or `null` 48 | */ 49 | getNearestCommand: ( 50 | commands: CommandGroup | CommandGroup[], 51 | options?: Omit, "language">, 52 | ) => string | null; 53 | 54 | /** 55 | * @param commands 56 | * @returns command entities hydrated with the custom prefixes 57 | */ 58 | getCommandEntities: ( 59 | commands: CommandGroup | CommandGroup[], 60 | ) => BotCommandEntity[]; 61 | 62 | /** 63 | * The matched command and the rest of the input. 64 | * 65 | * When matched command is a RegExp, a `match` property exposes the result of the RegExp match. 66 | */ 67 | commandMatch: CommandMatch; 68 | } 69 | 70 | /** 71 | * Installs the commands flavor into the context. 72 | */ 73 | export function commands() { 74 | return (ctx: CommandsFlavor, next: NextFunction) => { 75 | ctx.setMyCommands = async ( 76 | commands, 77 | options, 78 | ) => { 79 | if (!ctx.chat) { 80 | throw new Error( 81 | "cannot call `ctx.setMyCommands` on an update with no `chat` property", 82 | ); 83 | } 84 | 85 | const { 86 | uncompliantCommands, 87 | commandsParams: currentChatCommandParams, 88 | } = MyCommandParams.from( 89 | ensureArray(commands), 90 | ctx.chat.id, 91 | ); 92 | 93 | await setBotCommands( 94 | ctx.api, 95 | currentChatCommandParams, 96 | uncompliantCommands, 97 | options, 98 | ); 99 | }; 100 | 101 | ctx.getNearestCommand = (commands, options) => { 102 | if (!ctx.has(":text")) { 103 | throw new Error( 104 | "cannot call `ctx.getNearestCommand` on an update with no `text`", 105 | ); 106 | } 107 | 108 | const results = ensureArray(commands) 109 | .map((commands) => { 110 | const firstMatch = ctx.getCommandEntities(commands)[0]; 111 | const commandLike = firstMatch?.text.replace(firstMatch.prefix, "") || 112 | ""; 113 | const result = fuzzyMatch(commandLike, commands, { 114 | ...options, 115 | language: !options?.ignoreLocalization 116 | ? ctx.from?.language_code 117 | : undefined, 118 | }); 119 | return result; 120 | }).sort((a, b) => (b?.similarity ?? 0) - (a?.similarity ?? 0)); 121 | 122 | const result = results[0]; 123 | 124 | if (!result || !result.command) return null; 125 | 126 | return result.command.prefix + result.command.command; 127 | }; 128 | 129 | ctx.getCommandEntities = ( 130 | commands: CommandGroup | CommandGroup[], 131 | ) => { 132 | if (!ctx.has(":text")) { 133 | throw new Error( 134 | "cannot call `ctx.commandEntities` on an update with no `text`", 135 | ); 136 | } 137 | const text = ctx.msg.text; 138 | if (!text) return []; 139 | const prefixes = ensureArray(commands).flatMap((cmds) => cmds.prefixes); 140 | 141 | if (!prefixes.length) return []; 142 | 143 | const regexes = prefixes.map( 144 | (prefix) => getCommandsRegex(prefix), 145 | ); 146 | const entities = regexes.flatMap((regex) => { 147 | let match: RegExpExecArray | null; 148 | const matches = []; 149 | while ((match = regex.exec(text)) !== null) { 150 | const text = match[0].trim(); 151 | matches.push({ 152 | text, 153 | offset: match.index, 154 | prefix: match.groups!.prefix, 155 | type: "bot_command", 156 | length: text.length, 157 | }); 158 | } 159 | return matches as BotCommandEntity[]; 160 | }); 161 | 162 | return entities; 163 | }; 164 | 165 | return next(); 166 | }; 167 | } 168 | 169 | /** 170 | * Static class for getting and manipulating {@link SetMyCommandsParams}. 171 | * The main function is {@link from} 172 | */ 173 | export class MyCommandParams { 174 | /** 175 | * Merges and serialize one or more Commands instances into a single array 176 | * of commands params that can be used to set the commands menu displayed to the user. 177 | * @example 178 | ```ts 179 | const adminCommands = new CommandGroup(); 180 | const userCommands = new CommandGroup(); 181 | adminCommands 182 | .command("do a", 183 | "a description", 184 | (ctx) => ctx.doA()); 185 | userCommands 186 | .command("do b", 187 | "b description", 188 | (ctx) => ctx.doB()); 189 | const mergedParams = 190 | MyCommandParams.from([a, b], someChatId); 191 | ``` 192 | * @param commands An array of one or more Commands instances. 193 | * @returns an array of {@link SetMyCommandsParams} grouped by language 194 | */ 195 | static from( 196 | commands: CommandGroup[], 197 | chat_id: BotCommandScopeChat["chat_id"], 198 | ) { 199 | const serializedCommands = this._serialize(commands, chat_id); 200 | const commandsParams = serializedCommands 201 | .map(({ commandParams }) => commandParams) 202 | .flat(); 203 | 204 | const uncompliantCommands = serializedCommands 205 | .map(({ uncompliantCommands }) => uncompliantCommands) 206 | .flat(); 207 | 208 | return { 209 | commandsParams: this.mergeByLanguage(commandsParams), 210 | uncompliantCommands, 211 | }; 212 | } 213 | 214 | /** 215 | * Serializes one or multiple {@link CommandGroup} instances, each one into their respective 216 | * single scoped SetMyCommandsParams version. 217 | * @example 218 | ```ts 219 | const adminCommands = new CommandGroup(); 220 | // add to scope, localize, etc 221 | const userCommands = new CommandGroup(); 222 | // add to scope, localize, etc 223 | const [ 224 | singleScopedAdminParams, 225 | singleScopedUserParams 226 | ] = MyCommandsParams.serialize([adminCommands,userCommands]) 227 | ``` 228 | * @param commandsArr an array of one or more commands instances 229 | * @param chat_id the chat id relative to the message update, coming from the ctx object. 230 | * @returns an array of scoped {@link SetMyCommandsParams} mapped from their respective Commands instances 231 | */ 232 | static _serialize( 233 | commandsArr: CommandGroup[], 234 | chat_id: BotCommandScopeChat["chat_id"], 235 | ) { 236 | return commandsArr.map(( 237 | commands, 238 | ) => 239 | commands.toSingleScopeArgs({ 240 | type: "chat", 241 | chat_id, 242 | }) 243 | ); 244 | } 245 | 246 | /** 247 | * Lexicographically sorts commandParams based on their language code. 248 | * @returns the sorted array 249 | */ 250 | 251 | static _sortByLanguage(params: SetMyCommandsParams[]) { 252 | return params.sort((a, b) => { 253 | if (!a.language_code) return -1; 254 | if (!b.language_code) return 1; 255 | return a.language_code.localeCompare(b.language_code); 256 | }); 257 | } 258 | 259 | /** 260 | * Iterates over an array of CommandsParams 261 | * merging their respective {@link SetMyCommandsParams.commands} 262 | * when they are from the same language, separating when they are not. 263 | * 264 | * @param params a flattened array of commands params coming from one or more Commands instances 265 | * @returns an array containing all commands grouped by language 266 | */ 267 | 268 | private static mergeByLanguage(params: SetMyCommandsParams[]) { 269 | if (!params.length) return []; 270 | const sorted = this._sortByLanguage(params); 271 | return sorted.reduce((result, current, i, arr) => { 272 | if (i === 0 || current.language_code !== arr[i - 1].language_code) { 273 | result.push(current); 274 | return result; 275 | } else { 276 | result[result.length - 1].commands = result[result.length - 1] 277 | .commands 278 | .concat(current.commands); 279 | return result; 280 | } 281 | }, [] as SetMyCommandsParams[]); 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /src/deps.deno.ts: -------------------------------------------------------------------------------- 1 | export { 2 | Api, 3 | Bot, 4 | type ChatTypeContext, 5 | type ChatTypeMiddleware, 6 | type CommandContext, 7 | type CommandMiddleware, 8 | Composer, 9 | Context, 10 | type Middleware, 11 | type MiddlewareObj, 12 | type NextFunction, 13 | } from "https://lib.deno.dev/x/grammy@1/mod.ts"; 14 | export type { 15 | BotCommand, 16 | BotCommandScope, 17 | BotCommandScopeAllChatAdministrators, 18 | BotCommandScopeAllGroupChats, 19 | BotCommandScopeAllPrivateChats, 20 | BotCommandScopeChat, 21 | Chat, 22 | LanguageCode, 23 | MessageEntity, 24 | } from "https://lib.deno.dev/x/grammy@1/types.ts"; 25 | // TODO: bring this back once the types are available on the "web" runtimes 26 | // export { LanguageCodes } from "https://lib.deno.dev/x/grammy@1/types.ts"; 27 | -------------------------------------------------------------------------------- /src/deps.node.ts: -------------------------------------------------------------------------------- 1 | export { 2 | Api, 3 | Bot, 4 | type ChatTypeContext, 5 | type ChatTypeMiddleware, 6 | type CommandContext, 7 | type CommandMiddleware, 8 | Composer, 9 | Context, 10 | type Middleware, 11 | type MiddlewareObj, 12 | type NextFunction, 13 | } from "grammy"; 14 | export type { 15 | BotCommand, 16 | BotCommandScope, 17 | BotCommandScopeAllChatAdministrators, 18 | BotCommandScopeAllGroupChats, 19 | BotCommandScopeAllPrivateChats, 20 | BotCommandScopeChat, 21 | Chat, 22 | LanguageCode, 23 | MessageEntity 24 | } from "grammy/types"; 25 | // TODO: bring this back once the types are available on the "web" runtimes 26 | // export { LanguageCodes } from "grammy/types"; -------------------------------------------------------------------------------- /src/language-codes.ts: -------------------------------------------------------------------------------- 1 | // TODO: Remove this when we can import LanguageCodes from grammy web. See https://t.me/grammyjs/282375 2 | export const LanguageCodes = { 3 | Abkhazian: "ab", 4 | Afar: "aa", 5 | Afrikaans: "af", 6 | Akan: "ak", 7 | Albanian: "sq", 8 | Amharic: "am", 9 | Arabic: "ar", 10 | Aragonese: "an", 11 | Armenian: "hy", 12 | Assamese: "as", 13 | Avaric: "av", 14 | Avestan: "ae", 15 | Aymara: "ay", 16 | Azerbaijani: "az", 17 | Bambara: "bm", 18 | Bashkir: "ba", 19 | Basque: "eu", 20 | Belarusian: "be", 21 | Bengali: "bn", 22 | Bislama: "bi", 23 | Bosnian: "bs", 24 | Breton: "br", 25 | Bulgarian: "bg", 26 | Burmese: "my", 27 | Catalan: "ca", 28 | Chamorro: "ch", 29 | Chechen: "ce", 30 | Chichewa: "ny", 31 | Chinese: "zh", 32 | ChurchSlavonic: "cu", 33 | Chuvash: "cv", 34 | Cornish: "kw", 35 | Corsican: "co", 36 | Cree: "cr", 37 | Croatian: "hr", 38 | Czech: "cs", 39 | Danish: "da", 40 | Divehi: "dv", 41 | Dutch: "nl", 42 | Dzongkha: "dz", 43 | English: "en", 44 | Esperanto: "eo", 45 | Estonian: "et", 46 | Ewe: "ee", 47 | Faroese: "fo", 48 | Fijian: "fj", 49 | Finnish: "fi", 50 | French: "fr", 51 | WesternFrisian: "fy", 52 | Fulah: "ff", 53 | Gaelic: "gd", 54 | Galician: "gl", 55 | Ganda: "lg", 56 | Georgian: "ka", 57 | German: "de", 58 | Greek: "el", 59 | Kalaallisut: "kl", 60 | Guarani: "gn", 61 | Gujarati: "gu", 62 | Haitian: "ht", 63 | Hausa: "ha", 64 | Hebrew: "he", 65 | Herero: "hz", 66 | Hindi: "hi", 67 | HiriMotu: "ho", 68 | Hungarian: "hu", 69 | Icelandic: "is", 70 | Ido: "io", 71 | Igbo: "ig", 72 | Indonesian: "id", 73 | Interlingua: "ia", 74 | Interlingue: "ie", 75 | Inuktitut: "iu", 76 | Inupiaq: "ik", 77 | Irish: "ga", 78 | Italian: "it", 79 | Japanese: "ja", 80 | Javanese: "jv", 81 | Kannada: "kn", 82 | Kanuri: "kr", 83 | Kashmiri: "ks", 84 | Kazakh: "kk", 85 | CentralKhmer: "km", 86 | Kikuyu: "ki", 87 | Kinyarwanda: "rw", 88 | Kirghiz: "ky", 89 | Komi: "kv", 90 | Kongo: "kg", 91 | Korean: "ko", 92 | Kuanyama: "kj", 93 | Kurdish: "ku", 94 | Lao: "lo", 95 | Latin: "la", 96 | Latvian: "lv", 97 | Limburgan: "li", 98 | Lingala: "ln", 99 | Lithuanian: "lt", 100 | LubaKatanga: "lu", 101 | Luxembourgish: "lb", 102 | Macedonian: "mk", 103 | Malagasy: "mg", 104 | Malay: "ms", 105 | Malayalam: "ml", 106 | Maltese: "mt", 107 | Manx: "gv", 108 | Maori: "mi", 109 | Marathi: "mr", 110 | Marshallese: "mh", 111 | Mongolian: "mn", 112 | Nauru: "na", 113 | Navajo: "nv", 114 | NorthNdebele: "nd", 115 | SouthNdebele: "nr", 116 | Ndonga: "ng", 117 | Nepali: "ne", 118 | Norwegian: "no", 119 | NorwegianBokmål: "nb", 120 | NorwegianNynorsk: "nn", 121 | SichuanYi: "ii", 122 | Occitan: "oc", 123 | Ojibwa: "oj", 124 | Oriya: "or", 125 | Oromo: "om", 126 | Ossetian: "os", 127 | Pali: "pi", 128 | Pashto: "ps", 129 | Persian: "fa", 130 | Polish: "pl", 131 | Portuguese: "pt", 132 | Punjabi: "pa", 133 | Quechua: "qu", 134 | Romanian: "ro", 135 | Romansh: "rm", 136 | Rundi: "rn", 137 | Russian: "ru", 138 | NorthernSami: "se", 139 | Samoan: "sm", 140 | Sango: "sg", 141 | Sanskrit: "sa", 142 | Sardinian: "sc", 143 | Serbian: "sr", 144 | Shona: "sn", 145 | Sindhi: "sd", 146 | Sinhala: "si", 147 | Slovak: "sk", 148 | Slovenian: "sl", 149 | Somali: "so", 150 | SouthernSotho: "st", 151 | Spanish: "es", 152 | Sundanese: "su", 153 | Swahili: "sw", 154 | Swati: "ss", 155 | Swedish: "sv", 156 | Tagalog: "tl", 157 | Tahitian: "ty", 158 | Tajik: "tg", 159 | Tamil: "ta", 160 | Tatar: "tt", 161 | Telugu: "te", 162 | Thai: "th", 163 | Tibetan: "bo", 164 | Tigrinya: "ti", 165 | Tonga: "to", 166 | Tsonga: "ts", 167 | Tswana: "tn", 168 | Turkish: "tr", 169 | Turkmen: "tk", 170 | Twi: "tw", 171 | Uighur: "ug", 172 | Ukrainian: "uk", 173 | Urdu: "ur", 174 | Uzbek: "uz", 175 | Venda: "ve", 176 | Vietnamese: "vi", 177 | Volapük: "vo", 178 | Walloon: "wa", 179 | Welsh: "cy", 180 | Wolof: "wo", 181 | Xhosa: "xh", 182 | Yiddish: "yi", 183 | Yoruba: "yo", 184 | Zhuang: "za", 185 | Zulu: "zu", 186 | } as const; 187 | -------------------------------------------------------------------------------- /src/mod.ts: -------------------------------------------------------------------------------- 1 | export { Command } from "./command.ts"; 2 | export * from "./command-group.ts"; 3 | export * from "./context.ts"; 4 | export type { CommandOptions } from "./types.ts"; 5 | export { LanguageCodes } from "./language-codes.ts"; 6 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | BotCommand, 3 | BotCommandScope, 4 | LanguageCode, 5 | MessageEntity, 6 | } from "./deps.deno.ts"; 7 | 8 | /** 9 | * Supported command options 10 | */ 11 | export interface CommandOptions { 12 | /** 13 | * The prefix used to identify a command. 14 | * Defaults to `/`. 15 | */ 16 | prefix: string; 17 | /** 18 | * Whether the command should only be matched at the start of the message. 19 | * Defaults to `true`. 20 | */ 21 | matchOnlyAtStart: boolean; 22 | /** 23 | * Whether to ignore or only care about commands ending with the bot's username. 24 | * Defaults to `"optional"`. 25 | * 26 | * - `"ignored"`: only non-targeted commands are matched 27 | * - `"optional"`: both targeted and non-targeted commands are matched 28 | * - `"required"`: only targeted commands are matched 29 | */ 30 | targetedCommands: "ignored" | "optional" | "required"; 31 | /** 32 | * Whether match against commands in a case-insensitive manner. 33 | * Defaults to `false`. 34 | */ 35 | ignoreCase: boolean; 36 | } 37 | 38 | /** 39 | * BotCommand representation with more information about it. 40 | * Specially in regards to the plugin manipulation of it 41 | */ 42 | export interface BotCommandX extends BotCommand { 43 | prefix: string; 44 | /** 45 | * Language in which this command is localize 46 | */ 47 | language: LanguageCode | "default"; 48 | /** 49 | * Scopes in which this command is registered 50 | */ 51 | scopes: BotCommandScope[]; 52 | /** 53 | * True if this command has middleware attach to it. False if not. 54 | */ 55 | hasHandler: boolean; 56 | } 57 | 58 | /** represents a bot__command entity inside a text message */ 59 | export interface BotCommandEntity extends MessageEntity.CommonMessageEntity { 60 | type: "bot_command"; 61 | text: string; 62 | prefix: string; 63 | } 64 | -------------------------------------------------------------------------------- /src/utils/array.ts: -------------------------------------------------------------------------------- 1 | export type MaybeArray = T | T[]; 2 | export const ensureArray = (value: MaybeArray): T[] => 3 | Array.isArray(value) ? value : [value]; 4 | 5 | const specialChars = "\\.^$|?*+()[]{}-".split(""); 6 | 7 | const replaceAll = (s: string, find: string, replace: string) => 8 | s.replace(new RegExp(`\\${find}`, "g"), replace); 9 | 10 | export function escapeSpecial(str: string) { 11 | return specialChars.reduce( 12 | (acc, char) => replaceAll(acc, char, `\\${char}`), 13 | str, 14 | ); 15 | } 16 | export function getCommandsRegex(prefix: string) { 17 | return new RegExp( 18 | `(\?\<\!\\S)(\?${escapeSpecial(prefix)})\\S+(\\s|$)`, 19 | "g", 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/checks.ts: -------------------------------------------------------------------------------- 1 | import { Composer, Context, Middleware } from "../deps.deno.ts"; 2 | import { CommandOptions } from "../types.ts"; 3 | import { MaybeArray } from "./array.ts"; 4 | 5 | export function isAdmin(ctx: Context) { 6 | return ctx 7 | .getAuthor() 8 | .then((author) => ["administrator", "creator"].includes(author.status)); 9 | } 10 | 11 | export function isMiddleware( 12 | obj: unknown, 13 | ): obj is MaybeArray> { 14 | if (!obj) return false; 15 | if (obj instanceof Composer) return true; 16 | if (Array.isArray(obj)) return obj.every(isMiddleware); 17 | const objType = typeof obj; 18 | 19 | switch (objType) { 20 | case "function": 21 | return true; 22 | case "object": 23 | return Object.keys(obj).includes("middleware"); 24 | } 25 | 26 | return false; 27 | } 28 | export function isCommandOptions(obj: unknown): obj is Partial { 29 | if (typeof obj !== "object" || !obj) return false; 30 | const { prefix, matchOnlyAtStart, targetedCommands, ignoreCase } = 31 | obj as Partial; 32 | 33 | if (typeof prefix === "string") return true; 34 | if (typeof matchOnlyAtStart === "boolean") return true; 35 | if ( 36 | targetedCommands && 37 | ["ignored", "optional", "required"].includes(targetedCommands) 38 | ) return true; 39 | if (typeof ignoreCase === "boolean") return true; 40 | 41 | return false; 42 | } 43 | 44 | export function matchesPattern( 45 | value: string, 46 | pattern: string | RegExp, 47 | ignoreCase = false, 48 | ) { 49 | const transformedValue = ignoreCase ? value.toLowerCase() : value; 50 | const transformedPattern = 51 | pattern instanceof RegExp && ignoreCase && !pattern.flags.includes("i") 52 | ? new RegExp(pattern, pattern.flags + "i") 53 | : pattern; 54 | return typeof transformedPattern === "string" 55 | ? transformedValue === transformedPattern 56 | : transformedPattern.test(transformedValue); 57 | } 58 | -------------------------------------------------------------------------------- /src/utils/errors.ts: -------------------------------------------------------------------------------- 1 | import { UncompliantCommand } from "../command-group.ts"; 2 | import { BotCommandScope } from "../deps.deno.ts"; 3 | 4 | export class InvalidScopeError extends Error { 5 | constructor(scope: BotCommandScope) { 6 | super(`Invalid scope: ${scope}`); 7 | this.name = "InvalidScopeError"; 8 | } 9 | } 10 | 11 | export class CustomPrefixNotSupportedError extends Error { 12 | constructor(message: string, public readonly offendingCommands: string[]) { 13 | super(message); 14 | this.name = "CustomPrefixNotSupportedError"; 15 | } 16 | } 17 | 18 | export class UncompliantCommandsError extends Error { 19 | constructor( 20 | commands: Array, 21 | ) { 22 | const message = [ 23 | `Tried to set bot commands with one or more commands that do not comply with the Bot API requirements for command names. Offending command(s):`, 24 | commands.map(({ name, reasons, language }) => 25 | `- (language: ${language}) ${name}: ${reasons.join(", ")}` 26 | ) 27 | .join("\n"), 28 | "If you want to filter these commands out automatically, set `ignoreUncompliantCommands` to `true`", 29 | ].join("\n"); 30 | super(message); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/utils/jaro-winkler.ts: -------------------------------------------------------------------------------- 1 | import { CommandGroup } from "../command-group.ts"; 2 | import { Context, LanguageCode } from "../deps.deno.ts"; 3 | import type { BotCommandX } from "../types.ts"; 4 | import { LanguageCodes } from "../language-codes.ts"; 5 | 6 | export function distance(s1: string, s2: string) { 7 | if (s1.length === 0 || s2.length === 0) { 8 | return 0; 9 | } 10 | 11 | const matchWindow = Math.floor(Math.max(s1.length, s2.length) / 2.0) - 1; 12 | const matches1 = new Array(s1.length); 13 | const matches2 = new Array(s2.length); 14 | let m = 0; // number of matches 15 | let t = 0; // number of transpositions 16 | let i = 0; // index for string 1 17 | let k = 0; // index for string 2 18 | 19 | for (i = 0; i < s1.length; i++) { 20 | // loop to find matched characters 21 | const start = Math.max(0, i - matchWindow); // use the higher of the window diff 22 | const end = Math.min(i + matchWindow + 1, s2.length); // use the min of the window and string 2 length 23 | 24 | for (k = start; k < end; k++) { 25 | // iterate second string index 26 | if (matches2[k]) { 27 | // if second string character already matched 28 | continue; 29 | } 30 | if (s1[i] !== s2[k]) { 31 | // characters don't match 32 | continue; 33 | } 34 | 35 | // assume match if the above 2 checks don't continue 36 | matches1[i] = true; 37 | matches2[k] = true; 38 | m++; 39 | break; 40 | } 41 | } 42 | 43 | // nothing matched 44 | if (m === 0) { 45 | return 0.0; 46 | } 47 | 48 | k = 0; // reset string 2 index 49 | for (i = 0; i < s1.length; i++) { 50 | // loop to find transpositions 51 | if (!matches1[i]) { 52 | // non-matching character 53 | continue; 54 | } 55 | while (!matches2[k]) { 56 | // move k index to the next match 57 | k++; 58 | } 59 | if (s1[i] !== s2[k]) { 60 | // if the characters don't match, increase transposition 61 | // HtD: t is always less than the number of matches m, because transpositions are a subset of matches 62 | t++; 63 | } 64 | k++; // iterate k index normally 65 | } 66 | 67 | // transpositions divided by 2 68 | t /= 2.0; 69 | 70 | return (m / s1.length + m / s2.length + (m - t) / m) / 3.0; // HtD: therefore, m - t > 0, and m - t < m 71 | // HtD: => return value is between 0 and 1 72 | } 73 | 74 | export type JaroWinklerOptions = { 75 | ignoreCase?: boolean; 76 | similarityThreshold?: number; 77 | language?: LanguageCode | string; 78 | ignoreLocalization?: boolean; 79 | }; 80 | 81 | type CommandSimilarity = { 82 | command: BotCommandX | null; 83 | similarity: number; 84 | }; 85 | 86 | // Computes the Winkler distance between two string -- intrepreted from: 87 | // http://en.wikipedia.org/wiki/Jaro%E2%80%93Winkler_distance 88 | // s1 is the first string to compare 89 | // s2 is the second string to compare 90 | // dj is the Jaro Distance (if you've already computed it), leave blank and the method handles it 91 | // ignoreCase: if true strings are first converted to lower case before comparison 92 | export function JaroWinklerDistance( 93 | s1: string, 94 | s2: string, 95 | options: Pick, "ignoreCase">, 96 | ) { 97 | if (s1 === s2) { 98 | return 1; 99 | } else { 100 | if (options.ignoreCase) { 101 | s1 = s1.toLowerCase(); 102 | s2 = s2.toLowerCase(); 103 | } 104 | 105 | const jaro = distance(s1, s2); 106 | const p = 0.1; // default scaling factor 107 | let l = 0; // length of the matching prefix 108 | while (s1[l] === s2[l] && l < 4) { 109 | l++; 110 | } 111 | 112 | // HtD: 1 - jaro >= 0 113 | return jaro + l * p * (1 - jaro); 114 | } 115 | } 116 | 117 | export function isLanguageCode( 118 | value: string | undefined, 119 | ): value is LanguageCode { 120 | return Object.values(LanguageCodes).includes(value as LanguageCode); 121 | } 122 | 123 | export function fuzzyMatch( 124 | userInput: string, 125 | commands: CommandGroup, 126 | options: Partial, 127 | ): CommandSimilarity | null { 128 | const defaultSimilarityThreshold = 0.82; 129 | const similarityThreshold = options.similarityThreshold || 130 | defaultSimilarityThreshold; 131 | 132 | /** 133 | * ctx.from.id is IETF 134 | * https://en.wikipedia.org/wiki/IETF_language_tag 135 | */ 136 | const possiblyISO639 = options.language?.split("-")[0]; 137 | const language = isLanguageCode(possiblyISO639) ? possiblyISO639 : undefined; 138 | 139 | const cmds = options.ignoreLocalization 140 | ? commands.toElementals() 141 | : commands.toElementals(language); 142 | 143 | const bestMatch = cmds.reduce( 144 | (best: CommandSimilarity, command) => { 145 | const similarity = JaroWinklerDistance(userInput, command.command, { 146 | ...options, 147 | }); 148 | return similarity > best.similarity ? { command, similarity } : best; 149 | }, 150 | { command: null, similarity: 0 }, 151 | ); 152 | 153 | return bestMatch.similarity > similarityThreshold ? bestMatch : null; 154 | } 155 | -------------------------------------------------------------------------------- /src/utils/set-bot-commands.ts: -------------------------------------------------------------------------------- 1 | import { SetMyCommandsParams, UncompliantCommand } from "../command-group.ts"; 2 | import { Api } from "../deps.deno.ts"; 3 | import { UncompliantCommandsError } from "./errors.ts"; 4 | 5 | /** 6 | * Options for the `setBotCommands` function. 7 | */ 8 | export interface SetBotCommandsOptions { 9 | /** 10 | * Whether to remove invalid commands from the list of calls to the Bot API. 11 | * 12 | * If set to `false`, the method will throw an error if any of the commands 13 | * is invalid according to the {@link https://core.telegram.org/bots/api#botcommand|official Bot API documentation}. 14 | * 15 | * Defaults to `false`. 16 | */ 17 | ignoreUncompliantCommands?: boolean; 18 | } 19 | 20 | /** 21 | * Performs validation and sets the provided commands for the bot. 22 | * @param api Instance of the Api class for the bot the commands are being set for. 23 | * @param commandParams List of commands to set. 24 | * @param uncompliantCommands List of commands that do not comply with the Bot API rules. 25 | * @param options Options object` 26 | */ 27 | export async function setBotCommands( 28 | api: Api, 29 | commandParams: SetMyCommandsParams[], 30 | uncompliantCommands: UncompliantCommand[], 31 | options?: Partial, 32 | ) { 33 | const { ignoreUncompliantCommands = false } = options ?? {}; 34 | 35 | if (uncompliantCommands.length && !ignoreUncompliantCommands) { 36 | throw new UncompliantCommandsError(uncompliantCommands); 37 | } 38 | 39 | await Promise.all( 40 | commandParams.map((args) => api.raw.setMyCommands(args)), 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /test/command-group.test.ts: -------------------------------------------------------------------------------- 1 | import { CommandGroup } from "../src/command-group.ts"; 2 | import { MyCommandParams } from "../src/mod.ts"; 3 | import { dummyCtx } from "./context.test.ts"; 4 | import { 5 | assert, 6 | assertEquals, 7 | assertObjectMatch, 8 | assertRejects, 9 | assertThrows, 10 | describe, 11 | it, 12 | } from "./deps.test.ts"; 13 | 14 | describe("CommandGroup", () => { 15 | describe("command", () => { 16 | it("should create a command with no handlers", () => { 17 | const commands = new CommandGroup(); 18 | commands.command("test", "no handler"); 19 | 20 | assertObjectMatch(commands.toArgs().scopes[0], { 21 | commands: [ 22 | { 23 | command: "test", 24 | description: "no handler", 25 | hasHandler: false, 26 | }, 27 | ], 28 | }); 29 | }); 30 | 31 | it("should create a command with a default handler", () => { 32 | const commands = new CommandGroup(); 33 | commands.command("test", "default handler", () => {}, { 34 | prefix: undefined, 35 | }); 36 | 37 | const expected = [{ 38 | commands: [{ command: "test", description: "default handler" }], 39 | language_code: undefined, 40 | scope: { type: "default" }, 41 | }]; 42 | commands.toArgs().scopes.forEach((commands, i) => 43 | assertObjectMatch(commands, expected[i]) 44 | ); 45 | }); 46 | 47 | it("should support options with no handler", () => { 48 | const commands = new CommandGroup(); 49 | commands.command("test", "no handler", { prefix: "test" }); 50 | assertEquals( 51 | (commands as any)._commands[0]._options.prefix, 52 | "test", 53 | ); 54 | }); 55 | 56 | it("should support options with default handler", () => { 57 | const commands = new CommandGroup(); 58 | commands.command("test", "default handler", () => {}, { 59 | prefix: "test", 60 | }); 61 | assertEquals( 62 | (commands as any)._commands[0]._options.prefix, 63 | "test", 64 | ); 65 | }); 66 | }); 67 | describe("setMyCommands", () => { 68 | it("should throw if the update has no chat property", () => { 69 | const ctx = dummyCtx({ noMessage: true }); 70 | const a = new CommandGroup(); 71 | assertRejects(() => ctx.setMyCommands(a)); 72 | }); 73 | describe("toSingleScopeArgs", () => { 74 | it("should omit regex commands", () => { 75 | const commands = new CommandGroup(); 76 | commands.command("test", "handler1", (_) => _); 77 | commands.command("test2", "handler2", (_) => _); 78 | commands.command(/omitMe_\d\d/, "handler3", (_) => _); 79 | const params = commands.toSingleScopeArgs({ 80 | type: "chat", 81 | chat_id: 10, 82 | }); 83 | const expected = [{ 84 | commands: [ 85 | { command: "test", description: "handler1" }, 86 | { command: "test2", description: "handler2" }, 87 | ], 88 | }]; 89 | params.commandParams.forEach((commands, i) => 90 | assertObjectMatch(commands, expected[i]) 91 | ); 92 | }); 93 | it("should return an array with the localized versions of commands", () => { 94 | const commands = new CommandGroup(); 95 | commands.command("test", "handler1", (_) => _).localize( 96 | "es", 97 | "prueba1", 98 | "resolvedor1", 99 | ); 100 | commands.command("test2", "handler2", (_) => _); 101 | commands.command(/omitMe_\d\d/, "handler3", (_) => _).localize( 102 | "es", 103 | /omiteme_\d/, 104 | "resolvedor3", 105 | ); 106 | 107 | const params = commands.toSingleScopeArgs({ 108 | type: "chat", 109 | chat_id: 10, 110 | }); 111 | const expected = [ 112 | { 113 | scope: { type: "chat", chat_id: 10 }, 114 | language_code: undefined, 115 | commands: [ 116 | { command: "test", description: "handler1" }, 117 | { command: "test2", description: "handler2" }, 118 | ], 119 | }, 120 | { 121 | scope: { 122 | chat_id: 10, 123 | type: "chat", 124 | }, 125 | language_code: "es", 126 | commands: [ 127 | { 128 | command: "prueba1", 129 | description: "resolvedor1", 130 | }, 131 | { 132 | command: "test2", 133 | description: "handler2", 134 | }, 135 | ], 136 | }, 137 | ]; 138 | params.commandParams.forEach((command, i) => 139 | assertObjectMatch(command, expected[i]) 140 | ); 141 | }); 142 | it("should mark commands with no handler", () => { 143 | const commands = new CommandGroup(); 144 | commands.command("test", "handler", (_) => _); 145 | commands.command("markme", "nohandler"); 146 | const params = commands.toSingleScopeArgs({ 147 | type: "chat", 148 | chat_id: 10, 149 | }); 150 | assertEquals(params.commandParams, [ 151 | { 152 | scope: { type: "chat", chat_id: 10 }, 153 | language_code: undefined, 154 | commands: [ 155 | { command: "test", description: "handler", hasHandler: true }, 156 | { 157 | command: "markme", 158 | description: "nohandler", 159 | hasHandler: false, 160 | }, 161 | ], 162 | }, 163 | ]); 164 | }); 165 | it("should separate between compliant and uncompliant comands", () => { 166 | const commands = new CommandGroup(); 167 | commands.command("withcustomprefix", "handler", (_) => _, { 168 | prefix: "!", 169 | }); 170 | commands.command("withoutcustomprefix", "handler", (_) => _); 171 | 172 | const params = commands.toSingleScopeArgs({ 173 | type: "chat", 174 | chat_id: 10, 175 | }); 176 | assertEquals(params, { 177 | commandParams: [ 178 | { 179 | scope: { type: "chat", chat_id: 10 }, 180 | language_code: undefined, 181 | commands: [ 182 | { 183 | command: "withoutcustomprefix", 184 | description: "handler", 185 | hasHandler: true, 186 | }, 187 | ], 188 | }, 189 | ], 190 | uncompliantCommands: [ 191 | { 192 | name: "withcustomprefix", 193 | language: "default", 194 | reasons: ["Command has custom prefix: !"], 195 | }, 196 | ], 197 | }); 198 | }); 199 | }); 200 | describe("merge MyCommandsParams", () => { 201 | it("should merge command's from different Commands instances", () => { 202 | const a = new CommandGroup(); 203 | a.command("a", "test a", (_) => _); 204 | const b = new CommandGroup(); 205 | b.command("b", "test b", (_) => _); 206 | const c = new CommandGroup(); 207 | c.command("c", "test c", (_) => _); 208 | 209 | const mergedCommands = MyCommandParams.from([a, b, c], 10); 210 | const expected = [{ 211 | scope: { type: "chat", chat_id: 10 }, 212 | language_code: undefined, 213 | commands: [ 214 | { command: "c", description: "test c" }, 215 | { command: "b", description: "test b" }, 216 | { command: "a", description: "test a" }, 217 | ], 218 | }]; 219 | mergedCommands.commandsParams.forEach((command, i) => 220 | assertObjectMatch(command, expected[i]) 221 | ); 222 | }); 223 | it("should merge for localized scopes", () => { 224 | const a = new CommandGroup(); 225 | a.command("a", "test a", (_) => _); 226 | a.command("a1", "test a1", (_) => _).localize( 227 | "es", 228 | "locala1", 229 | "prueba a1 localizada", 230 | ); 231 | a.command("a2", "test a2", (_) => _).localize( 232 | "fr", 233 | "localisea2", 234 | "test a2 localisé", 235 | ); 236 | 237 | const b = new CommandGroup(); 238 | b.command("b", "test b", (_) => _) 239 | .localize("es", "localb", "prueba b localizada") 240 | .localize("fr", "localiseb", "prueba b localisé"); 241 | 242 | const mergedCommands = MyCommandParams.from([a, b], 10); 243 | const expected = [ 244 | { 245 | scope: { type: "chat", chat_id: 10 }, 246 | language_code: undefined, 247 | commands: [ 248 | { command: "b", description: "test b" }, 249 | { command: "a", description: "test a" }, 250 | { command: "a1", description: "test a1" }, 251 | { command: "a2", description: "test a2" }, 252 | ], 253 | }, 254 | { 255 | scope: { type: "chat", chat_id: 10 }, 256 | language_code: "es", 257 | commands: [ 258 | { command: "a", description: "test a" }, 259 | { 260 | command: "locala1", 261 | description: "prueba a1 localizada", 262 | }, 263 | { command: "a2", description: "test a2" }, 264 | { 265 | command: "localb", 266 | description: "prueba b localizada", 267 | }, 268 | ], 269 | }, 270 | { 271 | scope: { type: "chat", chat_id: 10 }, 272 | language_code: "fr", 273 | commands: [ 274 | { command: "a", description: "test a" }, 275 | { command: "a1", description: "test a1" }, 276 | { 277 | command: "localisea2", 278 | description: "test a2 localisé", 279 | }, 280 | { 281 | command: "localiseb", 282 | description: "prueba b localisé", 283 | }, 284 | ], 285 | }, 286 | ]; 287 | mergedCommands.commandsParams.forEach((command, i) => 288 | assertObjectMatch(command, expected[i]) 289 | ); 290 | }); 291 | }); 292 | describe("get all prefixes registered in a Commands instance", () => { 293 | const a = new CommandGroup(); 294 | a.command("a", "/", (_) => _); 295 | a.command("a2", "/", (_) => _); 296 | a.command("b", "?", (_) => _, { 297 | prefix: "?", 298 | }); 299 | a.command("c", "abcd", (_) => _, { 300 | prefix: "abcd", 301 | }); 302 | assertEquals(a.prefixes, ["/", "?", "abcd"]); 303 | }); 304 | describe("get Entities from an update", () => { 305 | const a = new CommandGroup(); 306 | a.command("a", "/", (_) => _); 307 | a.command("b", "?", (_) => _, { 308 | prefix: "?", 309 | }); 310 | a.command("c", "abcd", (_) => _, { 311 | prefix: "abcd", 312 | }); 313 | 314 | const b = new CommandGroup(); 315 | b.command("one", "normal", (_) => _, { prefix: "superprefix" }); 316 | const c = new CommandGroup(); 317 | 318 | it("should only consider as entities prefixes registered in the command instance", () => { 319 | const text = "/papi hola papacito como estamos /papi /ecco"; 320 | let ctx = dummyCtx({ 321 | userInput: text, 322 | }); 323 | const entities = ctx.getCommandEntities(a); 324 | for (const entity of entities) { 325 | assertEquals( 326 | text.substring( 327 | entity.offset, 328 | entity.offset + entity.length, 329 | ), 330 | entity.text, 331 | ); 332 | assert(!"hola papacito como estamos".includes(entity.text)); 333 | } 334 | }); 335 | it("should get command entities for custom prefixes", () => { 336 | let ctx = dummyCtx({ 337 | userInput: "/hi ?momi abcdfghi", 338 | }); 339 | const entities = ctx.getCommandEntities(a); 340 | assertEquals(entities, [ 341 | { 342 | text: "/hi", 343 | offset: 0, 344 | type: "bot_command", 345 | length: 3, 346 | prefix: "/", 347 | }, 348 | { 349 | text: "?momi", 350 | offset: 4, 351 | type: "bot_command", 352 | length: 5, 353 | prefix: "?", 354 | }, 355 | { 356 | text: "abcdfghi", 357 | offset: 10, 358 | type: "bot_command", 359 | length: 8, 360 | prefix: "abcd", 361 | }, 362 | ]); 363 | }); 364 | it("should throw if you call getCommandEntities on an update with no text", () => { 365 | const ctx = dummyCtx({}); 366 | assertThrows(() => ctx.getCommandEntities([a, b, c])); 367 | }); 368 | it("should return an empty array if the Commands classes to check against do not have any command register", () => { 369 | const ctx = dummyCtx({ userInput: "/papi" }); 370 | assertEquals(ctx.getCommandEntities(c), []); 371 | }); 372 | it("should work across multiple Commands instances", () => { 373 | const ctx = dummyCtx({ userInput: "/papi superprefixmami" }); 374 | assertEquals( 375 | ctx.getCommandEntities([a, b]).map((entity) => entity.prefix), 376 | ["/", "superprefix"], 377 | ); 378 | }); 379 | }); 380 | }); 381 | 382 | describe("toArgs", () => { 383 | it("should return an array of SetMyCommandsParams", () => { 384 | const commands = new CommandGroup(); 385 | commands.command("test", "handler", (_) => _); 386 | commands.command("test2", "handler2", (_) => _) 387 | .localize("es", "prueba2", "resolvedor2"); 388 | const params = commands.toArgs(); 389 | const expected = [ 390 | { 391 | commands: [ 392 | { command: "test", description: "handler" }, 393 | { command: "test2", description: "handler2" }, 394 | ], 395 | language_code: undefined, 396 | scope: { type: "default" }, 397 | }, 398 | { 399 | commands: [ 400 | { command: "test", description: "handler" }, 401 | { command: "prueba2", description: "resolvedor2" }, 402 | ], 403 | language_code: "es", 404 | scope: { type: "default" }, 405 | }, 406 | ]; 407 | params.scopes.forEach((command, i) => 408 | assertObjectMatch(command, expected[i]) 409 | ); 410 | }); 411 | 412 | it("should separate between compliant and uncompliant commands", () => { 413 | const commands = new CommandGroup(); 414 | commands.command("withcustomprefix", "handler", (_) => _, { 415 | prefix: "!", 416 | }); 417 | commands.command("withoutcustomprefix", "handler", (_) => _); 418 | 419 | const params = commands.toArgs(); 420 | assertEquals(params, { 421 | scopes: [ 422 | { 423 | scope: { type: "default" }, 424 | language_code: undefined, 425 | commands: [ 426 | { 427 | command: "withoutcustomprefix", 428 | description: "handler", 429 | hasHandler: true, 430 | }, 431 | ], 432 | }, 433 | ], 434 | uncompliantCommands: [ 435 | { 436 | name: "withcustomprefix", 437 | language: "default", 438 | reasons: ["Command has custom prefix: !"], 439 | }, 440 | ], 441 | }); 442 | }); 443 | }); 444 | }); 445 | -------------------------------------------------------------------------------- /test/command.test.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "../src/command.ts"; 2 | import { CommandOptions } from "../src/types.ts"; 3 | import { isCommandOptions, matchesPattern } from "../src/utils/checks.ts"; 4 | import { 5 | Api, 6 | assert, 7 | assertEquals, 8 | assertExists, 9 | assertFalse, 10 | assertSpyCalls, 11 | beforeEach, 12 | type Chat, 13 | type ChatMember, 14 | Context, 15 | describe, 16 | it, 17 | type Message, 18 | spy, 19 | type Update, 20 | type User, 21 | type UserFromGetMe, 22 | } from "./deps.test.ts"; 23 | import { Composer } from "../src/deps.deno.ts"; 24 | import { LanguageCodes } from "../src/language-codes.ts"; 25 | 26 | function createRegexpMatchArray( 27 | match: string[], 28 | groups?: Record, 29 | index?: number, 30 | input?: string, 31 | ) { 32 | const result = [...match]; 33 | (result as any).groups = groups; 34 | (result as any).index = index; 35 | (result as any).input = input; 36 | return result as unknown as RegExpExecArray; 37 | } 38 | 39 | describe("Command", () => { 40 | const u = { id: 42, first_name: "bot", is_bot: true } as User; 41 | const c = { id: 100, type: "private" } as Chat; 42 | const m = { 43 | text: "a", 44 | caption: undefined, 45 | from: u, 46 | chat: c, 47 | sender_chat: c, 48 | } as Message; 49 | const update = { 50 | message: m, 51 | edited_message: m, 52 | channel_post: m, 53 | edited_channel_post: m, 54 | inline_query: { id: "b", from: u, query: "iq" }, 55 | chosen_inline_result: { 56 | from: u, 57 | inline_message_id: "x", 58 | result_id: "p", 59 | }, 60 | callback_query: { 61 | data: "cb", 62 | game_short_name: "game", 63 | message: m, 64 | from: u, 65 | inline_message_id: "y", 66 | }, 67 | shipping_query: { id: "d", from: u }, 68 | pre_checkout_query: { id: "e", from: u }, 69 | poll: { id: "f" }, 70 | poll_answer: { poll_id: "g" }, 71 | my_chat_member: { date: 1, from: u, chat: c }, 72 | chat_member: { date: 2, from: u, chat: c }, 73 | chat_join_request: { date: 3, from: u, chat: c }, 74 | } as Update; 75 | 76 | const api = new Api("dummy-token"); 77 | const me = { id: 42, username: "bot" } as UserFromGetMe; 78 | const options: CommandOptions = { 79 | matchOnlyAtStart: true, 80 | prefix: "/", 81 | targetedCommands: "optional", 82 | ignoreCase: false, 83 | }; 84 | 85 | beforeEach(() => { 86 | m.caption = undefined; 87 | }); 88 | 89 | describe("hasCommand", () => { 90 | describe("default behavior", () => { 91 | it("should match a regular non-targeted command", () => { 92 | m.text = "/start"; 93 | m.entities = [{ type: "bot_command", offset: 0, length: 6 }]; 94 | const ctx = new Context(update, api, me); 95 | assert(Command.hasCommand("start", options)(ctx)); 96 | 97 | m.text = "blabla /start"; 98 | m.entities = [{ type: "bot_command", offset: 7, length: 6 }]; 99 | assertFalse(Command.hasCommand("start", options)(ctx)); 100 | }); 101 | 102 | it("should match a regular targeted command", () => { 103 | m.text = "/start@bot"; 104 | m.entities = [{ type: "bot_command", offset: 0, length: 10 }]; 105 | const ctx = new Context(update, api, me); 106 | assert(Command.hasCommand("start", options)(ctx)); 107 | }); 108 | 109 | it("should not match a regular targeted command in the middle of the message", () => { 110 | m.text = "blabla /start@bot"; 111 | m.entities = [{ type: "bot_command", offset: 7, length: 10 }]; 112 | const ctx = new Context(update, api, me); 113 | assertFalse(Command.hasCommand("start", options)(ctx)); 114 | }); 115 | 116 | it("should not match a regular targeted command with a different username", () => { 117 | m.text = "/start@otherbot"; 118 | m.entities = [{ type: "bot_command", offset: 0, length: 13 }]; 119 | const ctx = new Context(update, api, me); 120 | assertFalse(Command.hasCommand("start", options)(ctx)); 121 | }); 122 | 123 | it("should match regex commands", () => { 124 | m.text = "/start_123"; 125 | m.entities = [{ type: "bot_command", offset: 0, length: 10 }]; 126 | const ctx = new Context(update, api, me); 127 | assert(Command.hasCommand(/start_\d{3}/, options)(ctx)); 128 | 129 | m.text = "blabla /start_123"; 130 | m.entities = [{ type: "bot_command", offset: 7, length: 10 }]; 131 | assertFalse(Command.hasCommand(/start_\d{3}/, options)(ctx)); 132 | assert( 133 | Command.hasCommand(/start_\d{3}/, { 134 | ...options, 135 | matchOnlyAtStart: false, 136 | })(ctx), 137 | ); 138 | 139 | m.text = "/start_abc"; 140 | m.entities = [{ type: "bot_command", offset: 0, length: 10 }]; 141 | assertFalse(Command.hasCommand(/start_\d{3}/, options)(ctx)); 142 | 143 | m.text = "/start_123@bot"; 144 | m.entities = [{ type: "bot_command", offset: 0, length: 14 }]; 145 | assert(Command.hasCommand(/start_\d{3}/, options)(ctx)); 146 | }); 147 | 148 | it("should ignore a non-existing command", () => { 149 | m.text = "/start"; 150 | m.entities = [{ type: "bot_command", offset: 0, length: 6 }]; 151 | const ctx = new Context(update, api, me); 152 | assertFalse(Command.hasCommand("other", options)(ctx)); 153 | }); 154 | 155 | it("should not match a partial string command", () => { 156 | m.text = "/start_bla"; 157 | m.entities = [{ type: "bot_command", offset: 0, length: 10 }]; 158 | const ctx = new Context(update, api, me); 159 | assertFalse(Command.hasCommand("start", options)(ctx)); 160 | }); 161 | }); 162 | 163 | describe("matchOnlyAtStart", () => { 164 | it("should match a regular non-targeted command in the middle of the message", () => { 165 | m.text = "blabla /start"; 166 | m.entities = [{ type: "bot_command", offset: 7, length: 6 }]; 167 | const ctx = new Context(update, api, me); 168 | assert( 169 | Command.hasCommand("start", { 170 | ...options, 171 | matchOnlyAtStart: false, 172 | })(ctx), 173 | ); 174 | }); 175 | 176 | it("should match a regular targeted command in the middle of the message", () => { 177 | m.text = "blabla /start@bot"; 178 | m.entities = [{ type: "bot_command", offset: 7, length: 10 }]; 179 | const ctx = new Context(update, api, me); 180 | assert( 181 | Command.hasCommand("start", { 182 | ...options, 183 | matchOnlyAtStart: false, 184 | })(ctx), 185 | ); 186 | 187 | m.text = "blabla /start@otherbot"; 188 | m.entities = [{ type: "bot_command", offset: 7, length: 13 }]; 189 | assertFalse( 190 | Command.hasCommand("start", { 191 | ...options, 192 | matchOnlyAtStart: false, 193 | })(ctx), 194 | ); 195 | }); 196 | }); 197 | 198 | describe("prefix", () => { 199 | it("should match a non-targeted command with a custom prefix", () => { 200 | m.text = "!start"; 201 | m.entities = []; 202 | const ctx = new Context(update, api, me); 203 | assert( 204 | Command.hasCommand("start", { ...options, prefix: "!" })( 205 | ctx, 206 | ), 207 | ); 208 | 209 | m.text = "blabla !start"; 210 | assertFalse( 211 | Command.hasCommand("start", { ...options, prefix: "!" })( 212 | ctx, 213 | ), 214 | ); 215 | assert( 216 | Command.hasCommand("start", { 217 | ...options, 218 | prefix: "!", 219 | matchOnlyAtStart: false, 220 | })(ctx), 221 | ); 222 | }); 223 | 224 | it("should match a targeted command with a custom prefix", () => { 225 | m.text = "!start@bot"; 226 | const ctx = new Context(update, api, me); 227 | assert( 228 | Command.hasCommand("start", { ...options, prefix: "!" })( 229 | ctx, 230 | ), 231 | ); 232 | 233 | m.text = "blabla !start@bot"; 234 | assertFalse( 235 | Command.hasCommand("start", { ...options, prefix: "!" })( 236 | ctx, 237 | ), 238 | ); 239 | assert( 240 | Command.hasCommand("start", { 241 | ...options, 242 | prefix: "!", 243 | matchOnlyAtStart: false, 244 | })(ctx), 245 | ); 246 | 247 | m.text = "!start@otherbot"; 248 | assertFalse( 249 | Command.hasCommand("start", { ...options, prefix: "!" })( 250 | ctx, 251 | ), 252 | ); 253 | }); 254 | 255 | it("should ignore a non-existing command", () => { 256 | m.text = "!start"; 257 | m.entities = [{ type: "bot_command", offset: 0, length: 6 }]; 258 | const ctx = new Context(update, api, me); 259 | assertFalse( 260 | Command.hasCommand("other", { ...options, prefix: "!" })( 261 | ctx, 262 | ), 263 | ); 264 | }); 265 | }); 266 | 267 | describe("targetedCommands", () => { 268 | describe("ignored", () => { 269 | it("should match a non-targeted command", () => { 270 | m.text = "/start"; 271 | m.entities = [{ 272 | type: "bot_command", 273 | offset: 0, 274 | length: 6, 275 | }]; 276 | const ctx = new Context(update, api, me); 277 | assert( 278 | Command.hasCommand("start", { 279 | ...options, 280 | targetedCommands: "ignored", 281 | })(ctx), 282 | ); 283 | 284 | m.text = "blabla /start"; 285 | m.entities = [{ 286 | type: "bot_command", 287 | offset: 7, 288 | length: 6, 289 | }]; 290 | assertFalse( 291 | Command.hasCommand("start", { 292 | ...options, 293 | targetedCommands: "ignored", 294 | })(ctx), 295 | ); 296 | assert( 297 | Command.hasCommand("start", { 298 | ...options, 299 | targetedCommands: "ignored", 300 | matchOnlyAtStart: false, 301 | })(ctx), 302 | ); 303 | }); 304 | 305 | it("should ignore a targeted command", () => { 306 | m.text = "/start@bot"; 307 | m.entities = [{ 308 | type: "bot_command", 309 | offset: 0, 310 | length: 10, 311 | }]; 312 | const ctx = new Context(update, api, me); 313 | assertFalse( 314 | Command.hasCommand("start", { 315 | ...options, 316 | targetedCommands: "ignored", 317 | })(ctx), 318 | ); 319 | 320 | m.text = "blabla /start@bot"; 321 | m.entities = [{ 322 | type: "bot_command", 323 | offset: 7, 324 | length: 10, 325 | }]; 326 | assertFalse( 327 | Command.hasCommand("start", { 328 | ...options, 329 | targetedCommands: "ignored", 330 | })(ctx), 331 | ); 332 | 333 | m.text = "/start@otherbot"; 334 | m.entities = [{ 335 | type: "bot_command", 336 | offset: 0, 337 | length: 13, 338 | }]; 339 | assertFalse( 340 | Command.hasCommand("start", { 341 | ...options, 342 | targetedCommands: "ignored", 343 | })(ctx), 344 | ); 345 | }); 346 | }); 347 | 348 | describe("required", () => { 349 | it("should match a targeted command", () => { 350 | m.text = "/start@bot"; 351 | m.entities = [{ 352 | type: "bot_command", 353 | offset: 0, 354 | length: 10, 355 | }]; 356 | const ctx = new Context(update, api, me); 357 | assert( 358 | Command.hasCommand("start", { 359 | ...options, 360 | targetedCommands: "required", 361 | })(ctx), 362 | ); 363 | 364 | m.text = "blabla /start@bot"; 365 | m.entities = [{ 366 | type: "bot_command", 367 | offset: 7, 368 | length: 10, 369 | }]; 370 | assert( 371 | Command.hasCommand("start", { 372 | ...options, 373 | targetedCommands: "required", 374 | matchOnlyAtStart: false, 375 | })(ctx), 376 | ); 377 | 378 | m.text = "/start@otherbot"; 379 | m.entities = [{ 380 | type: "bot_command", 381 | offset: 0, 382 | length: 13, 383 | }]; 384 | assertFalse( 385 | Command.hasCommand("start", { 386 | ...options, 387 | targetedCommands: "required", 388 | })(ctx), 389 | ); 390 | }); 391 | 392 | it("should ignore a non-targeted command", () => { 393 | m.text = "/start"; 394 | m.entities = [{ 395 | type: "bot_command", 396 | offset: 0, 397 | length: 6, 398 | }]; 399 | const ctx = new Context(update, api, me); 400 | assertFalse( 401 | Command.hasCommand("start", { 402 | ...options, 403 | targetedCommands: "required", 404 | })(ctx), 405 | ); 406 | 407 | m.text = "blabla /start"; 408 | m.entities = [{ 409 | type: "bot_command", 410 | offset: 7, 411 | length: 6, 412 | }]; 413 | assertFalse( 414 | Command.hasCommand("start", { 415 | ...options, 416 | targetedCommands: "required", 417 | matchOnlyAtStart: false, 418 | })(ctx), 419 | ); 420 | }); 421 | }); 422 | }); 423 | 424 | describe("ignoreCase", () => { 425 | describe("true", () => { 426 | describe("for string commands", () => { 427 | it("should match a command in a case-insensitive manner", () => { 428 | m.text = "/START"; 429 | m.entities = [{ 430 | type: "bot_command", 431 | offset: 0, 432 | length: 6, 433 | }]; 434 | const ctx = new Context(update, api, me); 435 | assert( 436 | Command.hasCommand("start", { 437 | ...options, 438 | ignoreCase: true, 439 | })(ctx), 440 | ); 441 | m.text = "/start"; 442 | assert( 443 | Command.hasCommand("start", { 444 | ...options, 445 | ignoreCase: true, 446 | })(ctx), 447 | ); 448 | }); 449 | }); 450 | describe("for regex commands", () => { 451 | it("should match a command in a case-insensitive manner", () => { 452 | m.text = "/START"; 453 | m.entities = [{ 454 | type: "bot_command", 455 | offset: 0, 456 | length: 6, 457 | }]; 458 | const ctx = new Context(update, api, me); 459 | assert( 460 | Command.hasCommand(/start/, { 461 | ...options, 462 | ignoreCase: true, 463 | })(ctx), 464 | ); 465 | assert( 466 | Command.hasCommand(/start/i, { 467 | ...options, 468 | ignoreCase: true, 469 | })(ctx), 470 | ); 471 | m.text = "/start"; 472 | assert( 473 | Command.hasCommand(/sTaRt/, { 474 | ...options, 475 | ignoreCase: true, 476 | })(ctx), 477 | ); 478 | assert( 479 | Command.hasCommand(/sTaRt/i, { 480 | ...options, 481 | ignoreCase: true, 482 | })(ctx), 483 | ); 484 | }); 485 | }); 486 | }); 487 | 488 | describe("false", () => { 489 | describe("for string commands", () => { 490 | it("should match a command in a case-sensitive manner", () => { 491 | m.text = "/START"; 492 | m.entities = [{ 493 | type: "bot_command", 494 | offset: 0, 495 | length: 6, 496 | }]; 497 | const ctx = new Context(update, api, me); 498 | assertFalse( 499 | Command.hasCommand("start", { 500 | ...options, 501 | ignoreCase: false, 502 | })(ctx), 503 | ); 504 | 505 | m.text = "/start"; 506 | assert( 507 | Command.hasCommand("start", { 508 | ...options, 509 | ignoreCase: false, 510 | })(ctx), 511 | ); 512 | }); 513 | }); 514 | describe("for regex commands", () => { 515 | describe("should match a command in a case-sensitive manner", () => { 516 | it("under normal conditions", () => { 517 | m.text = "/START"; 518 | m.entities = [{ 519 | type: "bot_command", 520 | offset: 0, 521 | length: 6, 522 | }]; 523 | const ctx = new Context(update, api, me); 524 | assertFalse( 525 | Command.hasCommand(/start/, { 526 | ...options, 527 | ignoreCase: false, 528 | })(ctx), 529 | ); 530 | m.text = "/start"; 531 | assertFalse( 532 | Command.hasCommand(/START/, { 533 | ...options, 534 | ignoreCase: false, 535 | })(ctx), 536 | ); 537 | m.text = "/start"; 538 | assert( 539 | Command.hasCommand(/start/, { 540 | ...options, 541 | ignoreCase: false, 542 | })(ctx), 543 | ); 544 | }); 545 | }); 546 | it("should prioritize the `i` flag even if ignoreCase is set to false", () => { 547 | m.text = "/START"; 548 | m.entities = [{ 549 | type: "bot_command", 550 | offset: 0, 551 | length: 6, 552 | }]; 553 | const ctx = new Context(update, api, me); 554 | assert( 555 | Command.hasCommand(/start/i, { 556 | ...options, 557 | ignoreCase: false, 558 | })(ctx), 559 | ); 560 | }); 561 | }); 562 | }); 563 | }); 564 | }); 565 | 566 | describe("matchesPattern", () => { 567 | it("matches a string pattern", () => { 568 | assert(matchesPattern("start", "start")); 569 | }); 570 | 571 | it("matches a regex pattern", () => { 572 | assert(matchesPattern("start", /start/)); 573 | }); 574 | 575 | it("does not match an incorrect string pattern", () => { 576 | assertFalse(matchesPattern("start", "other")); 577 | }); 578 | 579 | it("does not match an incorrect regex pattern", () => { 580 | assertFalse(matchesPattern("start", /other/)); 581 | }); 582 | }); 583 | 584 | describe("isApiCompliant", () => { 585 | it("returns false if there is a custom prefix", () => { 586 | const command = new Command("test", "_", { prefix: "!" }); 587 | assertEquals(command.isApiCompliant(), [ 588 | false, 589 | "Command has custom prefix: !", 590 | ]); 591 | }); 592 | 593 | it("returns false if there is name is a regex", () => { 594 | const command = new Command(/test/, "_"); 595 | assertEquals(command.isApiCompliant(), [ 596 | false, 597 | "Command has a regular expression name", 598 | ]); 599 | }); 600 | 601 | it("returns false if there are uppercase characters", () => { 602 | const command = new Command("testCommand", "_"); 603 | assertEquals(command.isApiCompliant(), [ 604 | false, 605 | "Command name has uppercase characters", 606 | ]); 607 | }); 608 | 609 | it("returns false if command name is too long", () => { 610 | const command = new Command( 611 | "longnamelongnamelongnamelongnamelongname", 612 | "_", 613 | ); 614 | assertEquals(command.isApiCompliant(), [ 615 | false, 616 | "Command name is too long (40 characters). Maximum allowed is 32 characters", 617 | ]); 618 | }); 619 | 620 | it("returns false if command name has special characters", () => { 621 | const command = new Command("*test!", "_"); 622 | assertEquals(command.isApiCompliant(), [ 623 | false, 624 | "Command name has special characters (*!). Only letters, digits and _ are allowed", 625 | ]); 626 | }); 627 | 628 | it("is able to detect more than a problem at once", () => { 629 | const command = new Command( 630 | "$SUPERuncompli4ntCommand12345678", 631 | "_", 632 | ); 633 | assertEquals(command.isApiCompliant(), [ 634 | false, 635 | "Command name has uppercase characters", 636 | "Command name has special characters ($). Only letters, digits and _ are allowed", 637 | ]); 638 | }); 639 | }); 640 | 641 | describe("isCommandOptions", () => { 642 | it("true when an object contains valid CommandOptions properties", () => { 643 | let partialOpts: Partial = { prefix: "!" }; 644 | assert(isCommandOptions(partialOpts)); 645 | partialOpts = { matchOnlyAtStart: true }; 646 | assert(isCommandOptions(partialOpts)); 647 | partialOpts = { matchOnlyAtStart: false }; 648 | assert(isCommandOptions(partialOpts)); 649 | partialOpts = { targetedCommands: "ignored" }; 650 | assert(isCommandOptions(partialOpts)); 651 | partialOpts = { targetedCommands: "optional" }; 652 | assert(isCommandOptions(partialOpts)); 653 | partialOpts = { targetedCommands: "required" }; 654 | assert(isCommandOptions(partialOpts)); 655 | partialOpts = { ignoreCase: true }; 656 | assert(isCommandOptions(partialOpts)); 657 | }); 658 | it("should return false when an object contains invalid types for valid CommandOptions properties", () => { 659 | let partialOpts: any = { prefix: true }; 660 | assertFalse(isCommandOptions(partialOpts)); 661 | partialOpts = { ignoreCase: "false" }; 662 | assertFalse(isCommandOptions(partialOpts)); 663 | partialOpts = { targetedCommands: "requirred" }; 664 | assertFalse(isCommandOptions(partialOpts)); 665 | partialOpts = { ignoreCase: 1 }; 666 | assertFalse(isCommandOptions(partialOpts)); 667 | }); 668 | it("should return false when an object does not contain any CommandOption property", () => { 669 | let partialOpts: any = { papi: true }; 670 | assertFalse(isCommandOptions(partialOpts)); 671 | }); 672 | }); 673 | 674 | describe("findMatchingCommand", () => { 675 | it("should match a command in a caption", () => { 676 | m.text = undefined; 677 | m.caption = "/start"; 678 | const ctx = new Context(update, api, me); 679 | assert(Command.findMatchingCommand("start", options, ctx) !== null); 680 | }); 681 | 682 | it("should return null if the message does not contain a text or caption", () => { 683 | m.text = undefined; 684 | const ctx = new Context(update, api, me); 685 | assert(Command.findMatchingCommand("start", options, ctx) === null); 686 | }); 687 | 688 | it("should return null if the message does not start with the prefix and matchOnlyAtStart is true", () => { 689 | m.text = "/start"; 690 | const ctx = new Context(update, api, me); 691 | assertEquals( 692 | Command.findMatchingCommand("start", { 693 | ...options, 694 | prefix: "NOPE", 695 | matchOnlyAtStart: true, 696 | }, ctx), 697 | null, 698 | ); 699 | }); 700 | 701 | it("should correctly handle a targeted command", () => { 702 | m.text = "/start@bot"; 703 | const ctx = new Context(update, api, me); 704 | assertEquals( 705 | Command.findMatchingCommand("start", options, ctx), 706 | { 707 | command: "start", 708 | rest: "", 709 | }, 710 | ); 711 | }); 712 | 713 | it("should correctly handle a non-targeted command", () => { 714 | m.text = "/start"; 715 | const ctx = new Context(update, api, me); 716 | assertEquals( 717 | Command.findMatchingCommand("start", options, ctx), 718 | { 719 | command: "start", 720 | rest: "", 721 | }, 722 | ); 723 | }); 724 | 725 | it("should correctly handle a regex command with no args", () => { 726 | m.text = "/start_123"; 727 | const ctx = new Context(update, api, me); 728 | assertEquals( 729 | Command.findMatchingCommand(/start_(\d{3})/, options, ctx), 730 | { 731 | command: /start_(\d{3})/, 732 | rest: "", 733 | match: createRegexpMatchArray( 734 | ["start_123", "123"], 735 | undefined, 736 | 1, 737 | "/start_123", 738 | ), 739 | }, 740 | ); 741 | }); 742 | 743 | it("should correctly handle a regex command with args", () => { 744 | m.text = "/start blabla"; 745 | const ctx = new Context(update, api, me); 746 | const result = Command.findMatchingCommand(/start (.*)/, { 747 | ...options, 748 | targetedCommands: "optional", 749 | }, ctx); 750 | 751 | assertExists(result); 752 | assertEquals( 753 | result, 754 | { 755 | command: /start (.*)/, 756 | rest: "blabla", 757 | match: createRegexpMatchArray( 758 | ["start blabla", "blabla"], 759 | undefined, 760 | 1, 761 | "/start blabla", 762 | ), 763 | }, 764 | ); 765 | }); 766 | 767 | it("should handle a targeted command with a param that contains an @", () => { 768 | m.text = "/start@bot john@doe.com"; 769 | const ctx = new Context(update, api, me); 770 | assertEquals( 771 | Command.findMatchingCommand("start", options, ctx), 772 | { 773 | command: "start", 774 | rest: "john@doe.com", 775 | }, 776 | ); 777 | }); 778 | 779 | it("should handle a non-targeted command with a param that contains an @", () => { 780 | m.text = "/start john@doe.com"; 781 | const ctx = new Context(update, api, me); 782 | assertEquals(Command.findMatchingCommand("start", options, ctx), { 783 | command: "start", 784 | rest: "john@doe.com", 785 | }); 786 | }); 787 | 788 | it("should handle a command after an occurence of @", () => { 789 | m.text = "john@doe.com /start@bot test"; 790 | const ctx = new Context(update, api, me); 791 | assertEquals( 792 | Command.findMatchingCommand("start", { 793 | ...options, 794 | matchOnlyAtStart: false, 795 | }, ctx), 796 | { 797 | command: "start", 798 | rest: "test", 799 | }, 800 | ); 801 | }); 802 | }); 803 | 804 | describe("default handler", () => { 805 | it("should handle all localized names", async () => { 806 | const handler = spy(); 807 | const command = new Command("start", "test", handler) 808 | .localize(LanguageCodes.Portuguese, "iniciar", "test"); 809 | 810 | const composer = new Composer(); 811 | composer.use(command); 812 | 813 | let ctx = new Context( 814 | { ...update, message: { ...m, text: "/start" } } as Update, 815 | api, 816 | me, 817 | ); 818 | await composer.middleware()(ctx, () => Promise.resolve()); 819 | 820 | ctx = new Context( 821 | { ...update, message: { ...m, text: "/iniciar" } } as Update, 822 | api, 823 | me, 824 | ); 825 | await composer.middleware()(ctx, () => Promise.resolve()); 826 | 827 | assertEquals(handler.calls.length, 2); 828 | }); 829 | 830 | it("should handle localization after addToScope", async () => { 831 | const handler = spy(); 832 | const command = new Command("start", "test") 833 | .addToScope({ type: "default" }, handler) 834 | .localize(LanguageCodes.Portuguese, "iniciar", "test"); 835 | 836 | const composer = new Composer(); 837 | composer.use(command); 838 | 839 | let ctx = new Context( 840 | { ...update, message: { ...m, text: "/start" } } as Update, 841 | api, 842 | me, 843 | ); 844 | await composer.middleware()(ctx, () => Promise.resolve()); 845 | 846 | ctx = new Context( 847 | { ...update, message: { ...m, text: "/iniciar" } } as Update, 848 | api, 849 | me, 850 | ); 851 | await composer.middleware()(ctx, () => Promise.resolve()); 852 | 853 | assertEquals(handler.calls.length, 2); 854 | }); 855 | }); 856 | 857 | describe("addToScope", () => { 858 | // NOTE: currently the scopes need to be added in a priority order for the 859 | // narrowest function to be called 860 | const command = new Command("a", "Test command"); 861 | const mw = (ctx: Context) => 862 | command.middleware()(ctx, () => Promise.resolve()); 863 | const makeContext = (message: Message) => { 864 | const update = (message.chat.type === "channel") 865 | ? { channel_post: message as any, update_id: 1 } 866 | : { message: message as any, update_id: 1 }; 867 | return new Context(update, api, me); 868 | }; 869 | 870 | const chatMemberSpy = spy(); 871 | command.addToScope( 872 | { type: "chat_member", chat_id: -123, user_id: 456 }, 873 | chatMemberSpy, 874 | ); 875 | 876 | const chatAdministratorsSpy = spy(); 877 | command.addToScope( 878 | { type: "chat_administrators", chat_id: -123 }, 879 | chatAdministratorsSpy, 880 | ); 881 | 882 | const chatSpy = spy(); 883 | command.addToScope({ type: "chat", chat_id: -123 }, chatSpy); 884 | 885 | const privateChatSpy = spy(); 886 | command.addToScope({ type: "chat", chat_id: 456 }, privateChatSpy); 887 | 888 | const allChatAdministratorsSpy = spy(); 889 | command.addToScope( 890 | { type: "all_chat_administrators" }, 891 | allChatAdministratorsSpy, 892 | ); 893 | 894 | const allGroupChatsSpy = spy(); 895 | command.addToScope({ type: "all_group_chats" }, allGroupChatsSpy); 896 | 897 | const allPrivateChatsSpy = spy(); 898 | command.addToScope({ type: "all_private_chats" }, allPrivateChatsSpy); 899 | 900 | const defaultSpy = spy(); 901 | command.addToScope({ type: "default" }, defaultSpy); 902 | 903 | let chatMember: ChatMember | null = null; 904 | const api = { 905 | getChatMember: spy(() => { 906 | return Promise.resolve(chatMember); 907 | }), 908 | } as unknown as Api; 909 | beforeEach(() => { 910 | chatMember = { status: "member" } as ChatMember; 911 | }); 912 | 913 | it("should call chatMember", async () => { 914 | assertSpyCalls(chatMemberSpy, 0); 915 | await mw(makeContext({ 916 | chat: { id: -123, type: "group" }, 917 | from: { id: 456 }, 918 | text: "/a", 919 | } as Message)); 920 | assertSpyCalls(chatMemberSpy, 1); 921 | }); 922 | 923 | it("should call chatAdministrators", async () => { 924 | chatMember = { status: "administrator" } as ChatMember; 925 | 926 | assertSpyCalls(chatAdministratorsSpy, 0); 927 | await mw(makeContext({ 928 | chat: { id: -123, type: "group" }, 929 | from: { id: 789 }, 930 | text: "/a", 931 | } as Message)); 932 | assertSpyCalls(chatAdministratorsSpy, 1); 933 | }); 934 | 935 | it("should call chat", async () => { 936 | assertSpyCalls(chatSpy, 0); 937 | await mw(makeContext({ 938 | chat: { id: -123, type: "group" }, 939 | from: { id: 789 }, 940 | text: "/a", 941 | } as Message)); 942 | assertSpyCalls(chatSpy, 1); 943 | }); 944 | 945 | it("should call chat for a private chat", async () => { 946 | assertSpyCalls(privateChatSpy, 0); 947 | await mw(makeContext({ 948 | chat: { id: 456, type: "private" }, 949 | from: { id: 456 }, 950 | text: "/a", 951 | } as Message)); 952 | assertSpyCalls(privateChatSpy, 1); 953 | }); 954 | 955 | it("should call allChatAdministrators", async () => { 956 | chatMember = { status: "administrator" } as ChatMember; 957 | 958 | assertSpyCalls(allChatAdministratorsSpy, 0); 959 | await mw(makeContext({ 960 | chat: { id: -124, type: "group" }, 961 | from: { id: 789 }, 962 | text: "/a", 963 | } as Message)); 964 | assertSpyCalls(allChatAdministratorsSpy, 1); 965 | }); 966 | 967 | it("should call allGroupChats", async () => { 968 | chatMember = { status: "member" } as ChatMember; 969 | 970 | assertSpyCalls(allGroupChatsSpy, 0); 971 | await mw(makeContext({ 972 | chat: { id: -124, type: "group" }, 973 | from: { id: 789 }, 974 | text: "/a", 975 | } as Message)); 976 | assertSpyCalls(allGroupChatsSpy, 1); 977 | }); 978 | 979 | it("should call allPrivateChats", async () => { 980 | assertSpyCalls(allPrivateChatsSpy, 0); 981 | await mw(makeContext({ 982 | chat: { id: 789, type: "private" }, 983 | from: { id: 789 }, 984 | text: "/a", 985 | } as Message)); 986 | assertSpyCalls(allPrivateChatsSpy, 1); 987 | }); 988 | 989 | it("should call default", async () => { 990 | assertSpyCalls(defaultSpy, 0); 991 | await mw(makeContext({ 992 | chat: { id: -124, type: "channel" }, 993 | from: { id: 789 }, 994 | text: "/a", 995 | } as Message)); 996 | assertSpyCalls(defaultSpy, 1); 997 | }); 998 | }); 999 | }); 1000 | -------------------------------------------------------------------------------- /test/context.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | resolvesNext, 3 | spy, 4 | } from "https://deno.land/std@0.203.0/testing/mock.ts"; 5 | import { commands, type CommandsFlavor } from "../src/mod.ts"; 6 | import { 7 | Api, 8 | assert, 9 | assertRejects, 10 | Chat, 11 | Context, 12 | describe, 13 | it, 14 | Message, 15 | Update, 16 | User, 17 | UserFromGetMe, 18 | } from "./deps.test.ts"; 19 | 20 | describe("commands", () => { 21 | it("should install the setMyCommands method on the context", () => { 22 | const context = dummyCtx({}); 23 | 24 | const middleware = commands(); 25 | middleware(context, async () => {}); 26 | 27 | assert(context.setMyCommands); 28 | }); 29 | it("should install the getNearestCommand method on the context", () => { 30 | const context = dummyCtx({}); 31 | 32 | const middleware = commands(); 33 | middleware(context, async () => {}); 34 | 35 | assert(context.getNearestCommand); 36 | }); 37 | 38 | describe("setMyCommands", () => { 39 | it("should throw an error if there is no chat", async () => { 40 | const context = dummyCtx({ noMessage: true }); 41 | 42 | const middleware = commands(); 43 | middleware(context, async () => {}); 44 | 45 | await assertRejects( 46 | () => context.setMyCommands([]), 47 | Error, 48 | "cannot call `ctx.setMyCommands` on an update with no `chat` property", 49 | ); 50 | }); 51 | }); 52 | }); 53 | 54 | export function dummyCtx({ userInput, language, noMessage }: { 55 | userInput?: string; 56 | language?: string; 57 | noMessage?: boolean; 58 | }) { 59 | const u = { id: 42, first_name: "yo", language_code: language } as User; 60 | const c = { id: 100, type: "private" } as Chat; 61 | const m = noMessage ? undefined : ({ 62 | text: userInput, 63 | from: u, 64 | chat: c, 65 | } as Message); 66 | const update = { 67 | message: m, 68 | } as Update; 69 | const api = { 70 | raw: { setMyCommands: spy(resolvesNext([true] as const)) }, 71 | } as unknown as Api; 72 | const me = { id: 42, username: "bot" } as UserFromGetMe; 73 | const ctx = new Context(update, api, me) as CommandsFlavor; 74 | const middleware = commands(); 75 | middleware(ctx, async () => {}); 76 | return ctx; 77 | } 78 | -------------------------------------------------------------------------------- /test/deps.test.ts: -------------------------------------------------------------------------------- 1 | export { 2 | assert, 3 | assertEquals, 4 | assertExists, 5 | assertFalse, 6 | assertInstanceOf, 7 | assertNotStrictEquals, 8 | assertObjectMatch, 9 | assertRejects, 10 | assertStringIncludes, 11 | assertThrows, 12 | } from "https://deno.land/std@0.203.0/assert/mod.ts"; 13 | export { 14 | afterEach, 15 | beforeEach, 16 | describe, 17 | it, 18 | } from "https://deno.land/std@0.203.0/testing/bdd.ts"; 19 | export { 20 | assertSpyCall, 21 | assertSpyCalls, 22 | type Spy, 23 | spy, 24 | type Stub, 25 | stub, 26 | } from "https://deno.land/std@0.203.0/testing/mock.ts"; 27 | export { Api, Context } from "https://lib.deno.dev/x/grammy@1/mod.ts"; 28 | export type { 29 | Chat, 30 | ChatMember, 31 | Message, 32 | Update, 33 | User, 34 | UserFromGetMe, 35 | } from "https://lib.deno.dev/x/grammy@1/types.ts"; 36 | -------------------------------------------------------------------------------- /test/integration.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assertSpyCalls, 3 | resolvesNext, 4 | } from "https://deno.land/std@0.203.0/testing/mock.ts"; 5 | import { CommandGroup } from "../src/command-group.ts"; 6 | import { Bot } from "../src/deps.deno.ts"; 7 | import { Command, commands, CommandsFlavor } from "../src/mod.ts"; 8 | import { 9 | Api, 10 | assertRejects, 11 | assertSpyCall, 12 | Chat, 13 | Context, 14 | describe, 15 | it, 16 | Message, 17 | spy, 18 | Update, 19 | User, 20 | } from "./deps.test.ts"; 21 | 22 | const getBot = () => 23 | new Bot("dummy_token", { 24 | botInfo: { 25 | id: 1, 26 | is_bot: true, 27 | username: "", 28 | can_join_groups: true, 29 | can_read_all_group_messages: true, 30 | supports_inline_queries: true, 31 | first_name: "", 32 | can_connect_to_business: true, 33 | has_main_web_app: false, 34 | }, 35 | }); 36 | 37 | const getDummyUpdate = ({ userInput, language, noChat, chatType = "private" }: { 38 | userInput?: string; 39 | language?: string; 40 | noChat?: boolean; 41 | chatType?: Chat["type"]; 42 | } = {}) => { 43 | const u = { id: 42, first_name: "yo", language_code: language } as User; 44 | const c = { id: 100, type: chatType } as Chat; 45 | const m = { 46 | text: userInput, 47 | from: u, 48 | chat: noChat ? undefined : c, 49 | } as Message; 50 | const update = { 51 | message: m, 52 | } as Update; 53 | 54 | return update; 55 | }; 56 | 57 | describe("Integration", () => { 58 | describe("setCommands", () => { 59 | it("should call setMyCommands with valid commands", async () => { 60 | const myCommands = new CommandGroup(); 61 | myCommands.command("command", "_", (_, next) => next()); 62 | 63 | const setMyCommandsSpy = spy(resolvesNext([true] as const)); 64 | 65 | await myCommands.setCommands({ 66 | api: { 67 | raw: { setMyCommands: setMyCommandsSpy }, 68 | } as unknown as Api, 69 | }); 70 | 71 | assertSpyCalls(setMyCommandsSpy, 1); 72 | assertSpyCall(setMyCommandsSpy, 0, { 73 | args: [{ 74 | commands: [{ 75 | command: "command", 76 | description: "_", 77 | hasHandler: true, 78 | }], 79 | language_code: undefined, 80 | scope: { 81 | type: "default", 82 | }, 83 | }], 84 | }); 85 | }); 86 | 87 | it("should error when commands have custom prefixes", async () => { 88 | const myCommands = new CommandGroup({ prefix: "!" }); 89 | myCommands.command("command", "_", (_, next) => next()); 90 | 91 | const setMyCommandsSpy = spy(resolvesNext([true] as const)); 92 | 93 | await assertRejects(() => 94 | myCommands.setCommands({ 95 | api: { 96 | raw: { setMyCommands: setMyCommandsSpy }, 97 | } as unknown as Api, 98 | }) 99 | ); 100 | 101 | assertSpyCalls(setMyCommandsSpy, 0); 102 | }); 103 | it("should be able to set commands with no handler", async () => { 104 | const myCommands = new CommandGroup(); 105 | myCommands.command("command", "super description"); 106 | 107 | const setMyCommandsSpy = spy(resolvesNext([true] as const)); 108 | 109 | await myCommands.setCommands({ 110 | api: { 111 | raw: { setMyCommands: setMyCommandsSpy }, 112 | } as unknown as Api, 113 | }); 114 | 115 | assertSpyCalls(setMyCommandsSpy, 1); 116 | }); 117 | }); 118 | 119 | describe("ctx.setMyCommands", () => { 120 | it("should call setMyCommands with valid commands", async () => { 121 | const myCommands = new CommandGroup(); 122 | myCommands.command("command", "_", (_, next) => next()); 123 | 124 | const setMyCommandsSpy = spy(resolvesNext([true] as const)); 125 | const bot = getBot(); 126 | 127 | bot.api.config.use(async (prev, method, payload, signal) => { 128 | if (method !== "setMyCommands") { 129 | return prev(method, payload, signal); 130 | } 131 | await setMyCommandsSpy(payload); 132 | 133 | return { 134 | ok: true as const, 135 | result: true as ReturnType, 136 | }; 137 | }); 138 | 139 | bot.use(commands()); 140 | 141 | bot.use(async (ctx, next) => { 142 | await ctx.setMyCommands(myCommands); 143 | await next(); 144 | }); 145 | 146 | await bot.handleUpdate(getDummyUpdate()); 147 | 148 | assertSpyCalls(setMyCommandsSpy, 1); 149 | assertSpyCall(setMyCommandsSpy, 0, { 150 | args: [{ 151 | commands: [{ 152 | command: "command", 153 | description: "_", 154 | hasHandler: true, 155 | }], 156 | language_code: undefined, 157 | scope: { 158 | type: "chat", 159 | chat_id: 100, 160 | }, 161 | }], 162 | }); 163 | }); 164 | 165 | it("should error when commands have custom prefixes", async () => { 166 | const myCommands = new CommandGroup(); 167 | myCommands.command("command", "_", (_, next) => next(), { 168 | prefix: "!", 169 | }); 170 | 171 | const setMyCommandsSpy = spy(resolvesNext([true] as const)); 172 | const bot = getBot(); 173 | 174 | bot.api.config.use(async (prev, method, payload, signal) => { 175 | if (method !== "setMyCommands") { 176 | return prev(method, payload, signal); 177 | } 178 | await setMyCommandsSpy(payload); 179 | 180 | return { ok: true as const, result: true as ReturnType }; 181 | }); 182 | 183 | bot.use(commands()); 184 | 185 | bot.use(async (ctx, next) => { 186 | await assertRejects(() => ctx.setMyCommands(myCommands)); 187 | await next(); 188 | }); 189 | 190 | await bot.handleUpdate(getDummyUpdate()); 191 | 192 | assertSpyCalls(setMyCommandsSpy, 0); 193 | }); 194 | }); 195 | 196 | describe("CommandGroup", () => { 197 | describe("command", () => { 198 | it("should add a command with a default handler", async () => { 199 | const handler = spy(() => {}); 200 | 201 | const commandGroup = new CommandGroup(); 202 | commandGroup.command("command", "_", handler, { prefix: "!" }); 203 | 204 | const bot = getBot(); 205 | bot.use(commands()); 206 | bot.use(commandGroup); 207 | 208 | await bot.handleUpdate(getDummyUpdate({ userInput: "!command" })); 209 | 210 | assertSpyCalls(handler, 1); 211 | }); 212 | it("should prioritize manually added scopes over the default handler ", async () => { 213 | const defaultHandler = spy(() => {}); 214 | const specificHandler = spy(() => {}); 215 | 216 | const commandGroup = new CommandGroup(); 217 | commandGroup.command("command", "_", defaultHandler, { prefix: "!" }) 218 | .addToScope({ type: "all_group_chats" }, specificHandler); 219 | 220 | const bot = getBot(); 221 | bot.use(commands()); 222 | bot.use(commandGroup); 223 | 224 | await bot.handleUpdate( 225 | getDummyUpdate({ 226 | chatType: "group", 227 | userInput: "!command", 228 | }), 229 | ); 230 | assertSpyCalls(defaultHandler, 0); 231 | assertSpyCalls(specificHandler, 1); 232 | }); 233 | it("custom prefixed command with extra text", async () => { 234 | const handler = spy(() => {}); 235 | 236 | const commandGroup = new CommandGroup(); 237 | commandGroup.command("kick", "_", handler, { prefix: "!" }); 238 | 239 | const bot = getBot(); 240 | bot.use(commands()); 241 | bot.use(commandGroup); 242 | 243 | await bot.handleUpdate(getDummyUpdate({ userInput: "!kick 12345" })); 244 | 245 | assertSpyCalls(handler, 1); 246 | }); 247 | }); 248 | describe("add", () => { 249 | it("should add a command that was statically created", async () => { 250 | const handler = spy(() => {}); 251 | 252 | const commandGroup = new CommandGroup(); 253 | const cmd = new Command("command", "_", handler, { prefix: "!" }); 254 | commandGroup.add(cmd); 255 | 256 | const bot = getBot(); 257 | bot.use(commands()); 258 | bot.use(commandGroup); 259 | 260 | await bot.handleUpdate(getDummyUpdate({ userInput: "!command" })); 261 | 262 | assertSpyCalls(handler, 1); 263 | }); 264 | }); 265 | }); 266 | }); 267 | -------------------------------------------------------------------------------- /test/jaroWrinkler.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | distance, 3 | fuzzyMatch, 4 | JaroWinklerDistance, 5 | } from "../src/utils/jaro-winkler.ts"; 6 | import { CommandGroup } from "../src/mod.ts"; 7 | import { dummyCtx } from "./context.test.ts"; 8 | import { 9 | assertEquals, 10 | assertObjectMatch, 11 | assertThrows, 12 | Context, 13 | describe, 14 | it, 15 | } from "./deps.test.ts"; 16 | 17 | describe("Jaro-Wrinkler Algorithm", () => { 18 | it("should return value 0, because the empty string was given", () => { 19 | assertEquals(distance("", ""), 0); 20 | }); 21 | 22 | it("should return the correct similarity coefficient", () => { 23 | assertEquals(distance("hello", "hola"), 0.6333333333333333); 24 | }); 25 | 26 | it("should return value 1, because the strings are the same", () => { 27 | assertEquals(JaroWinklerDistance("hello", "hello", {}), 1); 28 | }); 29 | 30 | it("should return value 1, because case-sensitive is turn off", () => { 31 | assertEquals( 32 | JaroWinklerDistance("hello", "HELLO", { ignoreCase: true }), 33 | 1, 34 | ); 35 | }); 36 | 37 | describe("Fuzzy Matching", () => { 38 | it("should return the found command", () => { 39 | const cmds = new CommandGroup(); 40 | 41 | cmds.command( 42 | "start", 43 | "Starting", 44 | () => {}, 45 | ); 46 | assertEquals( 47 | fuzzyMatch("strt", cmds, { language: "fr" })?.command?.command, 48 | "start", 49 | ); 50 | }); 51 | 52 | it("should return null because command doesn't exist", () => { 53 | const cmds = new CommandGroup(); 54 | 55 | cmds.command( 56 | "start", 57 | "Starting", 58 | () => {}, 59 | ).addToScope( 60 | { type: "all_private_chats" }, 61 | (ctx) => ctx.reply(`Hello, ${ctx.chat.first_name}!`), 62 | ); 63 | 64 | assertEquals(fuzzyMatch("xyz", cmds, {}), null); 65 | }); 66 | 67 | it("should work for simple regex commands", () => { 68 | const cmds = new CommandGroup(); 69 | cmds.command( 70 | /magical_\d/, 71 | "Magical Command", 72 | ).addToScope( 73 | { type: "all_private_chats" }, 74 | (ctx) => ctx.reply(`Hello, ${ctx.chat.first_name}!`), 75 | ); 76 | assertEquals( 77 | fuzzyMatch("magcal", cmds, { language: "fr" })?.command?.command, 78 | "magical_\\d", 79 | ); 80 | }); 81 | it("should work for localized regex", () => { 82 | const cmds = new CommandGroup(); 83 | cmds.command( 84 | /magical_(a|b)/, 85 | "Magical Command", 86 | ).addToScope( 87 | { type: "all_private_chats" }, 88 | (ctx) => ctx.reply(`Hello, ${ctx.chat.first_name}!`), 89 | ).localize("es", /magico_(c|d)/, "Comando Mágico"); 90 | 91 | assertEquals( 92 | fuzzyMatch("magici_c", cmds, { language: "es" })?.command?.command, 93 | "magico_(c|d)", 94 | ); 95 | assertEquals( 96 | fuzzyMatch("magici_a", cmds, { language: "fr" })?.command?.command, 97 | "magical_(a|b)", 98 | ); 99 | }); 100 | }); 101 | describe("Serialize commands for FuzzyMatch", () => { 102 | describe("toNameAndPrefix", () => { 103 | const cmds = new CommandGroup(); 104 | cmds.command("butcher", "_", () => {}, { prefix: "?" }) 105 | .localize("es", "carnicero", "a") 106 | .localize("it", "macellaio", "b"); 107 | 108 | cmds.command("duke", "_", () => {}) 109 | .localize("es", "duque", "c") 110 | .localize("fr", "duc", "d"); 111 | 112 | cmds.command(/dad_(.*)/, "dad", () => {}) 113 | .localize("es", /papa_(.*)/, "f"); 114 | it("should output all commands names, language and prefix, and description", () => { 115 | const json = cmds.toElementals(); 116 | const expected = [ 117 | { 118 | command: "butcher", 119 | language: "default", 120 | prefix: "?", 121 | description: "_", 122 | }, 123 | { 124 | command: "carnicero", 125 | language: "es", 126 | prefix: "?", 127 | description: "a", 128 | }, 129 | { 130 | command: "macellaio", 131 | language: "it", 132 | prefix: "?", 133 | description: "b", 134 | }, 135 | { 136 | command: "duke", 137 | language: "default", 138 | prefix: "/", 139 | description: "_", 140 | }, 141 | { 142 | command: "duque", 143 | language: "es", 144 | prefix: "/", 145 | description: "c", 146 | }, 147 | { 148 | command: "duc", 149 | language: "fr", 150 | prefix: "/", 151 | description: "d", 152 | }, 153 | { 154 | command: "dad_(.*)", 155 | language: "default", 156 | prefix: "/", 157 | description: "dad", 158 | }, 159 | { 160 | command: "papa_(.*)", 161 | language: "es", 162 | prefix: "/", 163 | description: "f", 164 | }, 165 | ]; 166 | json.forEach((command, i) => { 167 | assertObjectMatch(command, expected[i]); 168 | }); 169 | }); 170 | }); 171 | describe("should return the command localization related to the user lang", () => { 172 | const cmds = new CommandGroup(); 173 | cmds.command("duke", "sniper", () => {}) 174 | .localize("es", "duque", "_") 175 | .localize("fr", "duc", "_") 176 | .localize("it", "duca", "_") 177 | .localize("pt", "duque", "_") 178 | .localize("de", "herzog", "_") 179 | .localize("sv", "hertig", "_") 180 | .localize("da", "hertug", "_") 181 | .localize("fi", "herttua", "_") 182 | .localize("hu", "herceg", "_"); 183 | 184 | it("sv", () => { 185 | assertEquals( 186 | fuzzyMatch("hertog", cmds, { language: "sv" })?.command 187 | ?.command, 188 | "hertig", 189 | ); 190 | }); 191 | it("da", () => { 192 | assertEquals( 193 | fuzzyMatch("hertog", cmds, { language: "da" })?.command 194 | ?.command, 195 | "hertug", 196 | ); 197 | }); 198 | describe("default", () => { 199 | it("duke", () => 200 | assertEquals( 201 | fuzzyMatch("duk", cmds, {})?.command?.command, 202 | "duke", 203 | )); 204 | it("duke", () => 205 | assertEquals( 206 | fuzzyMatch("due", cmds, {})?.command?.command, 207 | "duke", 208 | )); 209 | it("duke", () => 210 | assertEquals( 211 | fuzzyMatch("dule", cmds, {})?.command?.command, 212 | "duke", 213 | )); 214 | it("duke", () => 215 | assertEquals( 216 | fuzzyMatch("duje", cmds, {})?.command?.command, 217 | "duke", 218 | )); 219 | }); 220 | describe("es", () => { 221 | it("duque", () => 222 | assertEquals( 223 | fuzzyMatch("duquw", cmds, { language: "es" })?.command 224 | ?.command, 225 | "duque", 226 | )); 227 | it("duque", () => 228 | assertEquals( 229 | fuzzyMatch("duqe", cmds, { language: "es" })?.command 230 | ?.command, 231 | "duque", 232 | )); 233 | it("duque", () => 234 | assertEquals( 235 | fuzzyMatch("duwue", cmds, { language: "es" })?.command 236 | ?.command, 237 | "duque", 238 | )); 239 | }); 240 | describe("fr", () => { 241 | it("duc", () => 242 | assertEquals( 243 | fuzzyMatch("duk", cmds, { language: "fr" })?.command 244 | ?.command, 245 | "duc", 246 | )); 247 | it("duc", () => 248 | assertEquals( 249 | fuzzyMatch("duce", cmds, { language: "fr" })?.command 250 | ?.command, 251 | "duc", 252 | )); 253 | it("duc", () => 254 | assertEquals( 255 | fuzzyMatch("ducñ", cmds, { language: "fr" })?.command 256 | ?.command, 257 | "duc", 258 | )); 259 | }); 260 | }); 261 | describe("should return the command localization related to the user lang for similar command names from different command classes", () => { 262 | const cmds = new CommandGroup(); 263 | cmds.command("push", "push", () => {}) 264 | .localize("fr", "pousser", "a") 265 | .localize("pt", "empurrar", "b"); 266 | 267 | cmds.command("rest", "rest", () => {}) 268 | .localize("fr", "reposer", "c") 269 | .localize("pt", "poussar", "d"); 270 | 271 | describe("pt rest", () => { 272 | it("poussar", () => 273 | assertEquals( 274 | fuzzyMatch("pousssr", cmds, { language: "pt" })?.command 275 | ?.command, 276 | "poussar", 277 | )); 278 | it("poussar", () => 279 | assertEquals( 280 | fuzzyMatch("pousar", cmds, { language: "pt" })?.command 281 | ?.command, 282 | "poussar", 283 | )); 284 | it("poussar", () => 285 | assertEquals( 286 | fuzzyMatch("poussqr", cmds, { language: "pt" })?.command 287 | ?.command, 288 | "poussar", 289 | )); 290 | it("poussar", () => 291 | assertEquals( 292 | fuzzyMatch("poussrr", cmds, { language: "pt" })?.command 293 | ?.command, 294 | "poussar", 295 | )); 296 | }); 297 | describe("fr push", () => { 298 | it("pousser", () => 299 | assertEquals( 300 | fuzzyMatch("pousssr", cmds, { language: "fr" })?.command 301 | ?.command, 302 | "pousser", 303 | )); 304 | it("pousser", () => 305 | assertEquals( 306 | fuzzyMatch("pouser", cmds, { language: "fr" })?.command 307 | ?.command, 308 | "pousser", 309 | )); 310 | it("pousser", () => 311 | assertEquals( 312 | fuzzyMatch("pousrr", cmds, { language: "fr" })?.command 313 | ?.command, 314 | "pousser", 315 | )); 316 | it("pousser", () => 317 | assertEquals( 318 | fuzzyMatch("poussrr", cmds, { language: "fr" })?.command 319 | ?.command, 320 | "pousser", 321 | )); 322 | }); 323 | }); 324 | }); 325 | describe("Usage inside ctx", () => { 326 | const cmds = new CommandGroup(); 327 | cmds.command("butcher", "_", () => {}, { prefix: "+" }) 328 | .localize("es", "carnicero", "_") 329 | .localize("it", "macellaio", "_"); 330 | 331 | cmds.command("duke", "_", () => {}) 332 | .localize("es", "duque", "_") 333 | .localize("fr", "duc", "_"); 334 | 335 | cmds.command("daddy", "me", () => {}, { prefix: "?" }) 336 | .localize("es", "papito", "yeyo"); 337 | 338 | cmds.command("ender", "_", () => {}); 339 | cmds.command("endanger", "_", () => {}); 340 | cmds.command("entitle", "_", () => {}); 341 | 342 | it("should throw when no msg is given", () => { 343 | let ctx = dummyCtx({}); 344 | assertThrows(() => ctx.getNearestCommand(cmds)); 345 | }); 346 | 347 | describe("should ignore localization when set to, and search trough all commands", () => { 348 | it("ignore even if the language is set", () => { // should this console.warn? or maybe use an overload? 349 | let ctx = dummyCtx({ 350 | userInput: "/duci", 351 | language: "es", 352 | }); 353 | assertEquals( 354 | ctx.getNearestCommand(cmds, { 355 | ignoreLocalization: true, 356 | }), 357 | "/duc", 358 | ); 359 | ctx = dummyCtx({ 360 | userInput: "/duki", 361 | language: "es", 362 | }); 363 | assertEquals( 364 | ctx.getNearestCommand(cmds, { 365 | ignoreLocalization: true, 366 | }), 367 | "/duke", 368 | ); 369 | }); 370 | it("ignore when the language is not set", () => { 371 | let ctx = dummyCtx({ 372 | userInput: "/duki", 373 | language: "es", 374 | }); 375 | assertEquals( 376 | ctx.getNearestCommand(cmds, { ignoreLocalization: true }), 377 | "/duke", 378 | ); 379 | ctx = dummyCtx({ 380 | userInput: "/macellaoo", 381 | language: "es", 382 | }); 383 | assertEquals( 384 | ctx.getNearestCommand(cmds, { ignoreLocalization: true }), 385 | "+macellaio", 386 | ); 387 | ctx = dummyCtx({ 388 | userInput: "/dadd", 389 | language: "es", 390 | }); 391 | assertEquals( 392 | ctx.getNearestCommand(cmds, { ignoreLocalization: true }), 393 | "?daddy", 394 | ); 395 | ctx = dummyCtx({ 396 | userInput: "/duk", 397 | language: "es", 398 | }); 399 | assertEquals( 400 | ctx.getNearestCommand(cmds, { ignoreLocalization: true }), 401 | "/duke", 402 | ); 403 | }); 404 | it("should not restrict itself to default", () => { 405 | let ctx = dummyCtx({ 406 | userInput: "/duqu", 407 | language: "es", 408 | }); 409 | assertEquals( 410 | ctx.getNearestCommand(cmds, { ignoreLocalization: true }), 411 | "/duque", 412 | ); 413 | }); 414 | it("language not know, but ignore localization still matches the best similarity", () => { 415 | let ctx = dummyCtx({ 416 | userInput: "/duqu", 417 | language: "en-papacito", 418 | }); 419 | assertEquals( 420 | ctx.getNearestCommand(cmds, { ignoreLocalization: true }), 421 | "/duque", 422 | ); 423 | }); 424 | it("should chose localization if not ignore", () => { 425 | let ctx = dummyCtx({ 426 | userInput: "/duku", 427 | language: "es", 428 | }); 429 | assertEquals( 430 | ctx.getNearestCommand(cmds), 431 | "/duque", 432 | ); 433 | ctx = dummyCtx({ 434 | userInput: "/duk", 435 | language: "fr", 436 | }); 437 | assertEquals( 438 | ctx.getNearestCommand(cmds), 439 | "/duc", 440 | ); 441 | }); 442 | }); 443 | describe("should not fail even if the language it's not know", () => { 444 | it("should fallback to default", () => { 445 | let ctx = dummyCtx({ 446 | userInput: "/duko", 447 | language: "en-papacito", 448 | }); 449 | assertEquals(ctx.getNearestCommand(cmds), "/duke"); 450 | ctx = dummyCtx({ 451 | userInput: "/butxher", 452 | language: "no-language", 453 | }); 454 | assertEquals(ctx.getNearestCommand(cmds), "+butcher"); 455 | }); 456 | }); 457 | describe("should work for commands with no localization, even when the language is set", () => { 458 | it("ender", () => { 459 | let ctx = dummyCtx({ 460 | userInput: "/endr", 461 | language: "es", 462 | }); 463 | assertEquals(ctx.getNearestCommand(cmds), "/ender"); 464 | }); 465 | it("endanger", () => { 466 | let ctx = dummyCtx({ 467 | userInput: "/enanger", 468 | language: "en", 469 | }); 470 | assertEquals(ctx.getNearestCommand(cmds), "/endanger"); 471 | }); 472 | it("entitle", () => { 473 | let ctx = dummyCtx({ 474 | userInput: "/entities", 475 | language: "pt", 476 | }); 477 | assertEquals(ctx.getNearestCommand(cmds), "/entitle"); 478 | }); 479 | }); 480 | }); 481 | describe("Test multiple commands instances", () => { 482 | const cmds = new CommandGroup(); 483 | cmds.command("bread", "_", () => {}) 484 | .localize("es", "pan", "_") 485 | .localize("fr", "pain", "_"); 486 | 487 | const cmds2 = new CommandGroup(); 488 | 489 | cmds2.command("dad", "_", () => {}) 490 | .localize("es", "papa", "_") 491 | .localize("fr", "pere", "_"); 492 | 493 | it("should get the nearest between multiple command classes", () => { 494 | let ctx = dummyCtx({ 495 | userInput: "/papi", 496 | language: "es", 497 | }); 498 | assertEquals(ctx.getNearestCommand([cmds, cmds2]), "/papa"); 499 | ctx = dummyCtx({ 500 | userInput: "/pai", 501 | language: "fr", 502 | }); 503 | assertEquals(ctx.getNearestCommand([cmds, cmds2]), "/pain"); 504 | }); 505 | it("Without localization it should get the best between multiple command classes", () => { 506 | let ctx = dummyCtx({ 507 | userInput: "/pana", 508 | language: "???", 509 | }); 510 | assertEquals( 511 | ctx.getNearestCommand([cmds, cmds2], { 512 | ignoreLocalization: true, 513 | }), 514 | "/pan", 515 | ); 516 | ctx = dummyCtx({ 517 | userInput: "/para", 518 | language: "???", 519 | }); 520 | assertEquals( 521 | ctx.getNearestCommand([cmds, cmds2], { 522 | ignoreLocalization: true, 523 | }), 524 | "/papa", 525 | ); 526 | }); 527 | }); 528 | }); 529 | -------------------------------------------------------------------------------- /test/not-found.test.ts: -------------------------------------------------------------------------------- 1 | import { CommandsFlavor } from "../src/context.ts"; 2 | import { CommandGroup, commandNotFound } from "../src/mod.ts"; 3 | import { dummyCtx } from "./context.test.ts"; 4 | import { 5 | assert, 6 | assertEquals, 7 | assertFalse, 8 | Context, 9 | describe, 10 | it, 11 | } from "./deps.test.ts"; 12 | 13 | describe("commandNotFound", () => { 14 | describe("for inputs containing '/' commands", () => { 15 | const ctx = dummyCtx({ userInput: "/papacin /papazote" }); 16 | it("should return true when no commands are registered", () => { 17 | const cmds = new CommandGroup(); 18 | const predicate = commandNotFound(cmds); 19 | assert(predicate(ctx)); 20 | }); 21 | it("should return true when only '/' commands are registered", () => { 22 | const cmds = new CommandGroup(); 23 | cmds.command("papacito", "", (_) => _); 24 | const predicate = commandNotFound(cmds); 25 | assert(predicate(ctx)); 26 | }); 27 | it("should return false when there is only custom prefixed commands registered", () => { 28 | const cmds = new CommandGroup(); 29 | cmds.command("papazote", "", (_) => _, { prefix: "?" }); 30 | const predicate = commandNotFound(cmds); 31 | assertFalse(predicate(ctx)); 32 | }); 33 | }); 34 | describe("for inputs containing custom prefixed commands", () => { 35 | const ctx = dummyCtx({ userInput: "?papacin +papazote" }); 36 | 37 | it("should return false if only '/' commands are registered", () => { 38 | const cmds = new CommandGroup(); 39 | cmds.command("papacito", "", (_) => _); 40 | const predicate = commandNotFound(cmds); 41 | assertFalse(predicate(ctx)); 42 | }); 43 | it("should return false for customs registered not matching the input", () => { 44 | const cmds = new CommandGroup(); 45 | cmds.command("papacito", "", (_) => _, { prefix: "&" }); 46 | const predicate = commandNotFound(cmds); 47 | assertFalse(predicate(ctx)); 48 | }); 49 | it("should return true for exact matching the input", () => { 50 | const cmds = new CommandGroup(); 51 | cmds.command("papacin", "", (_) => _, { prefix: "?" }); 52 | cmds.command("papazote", "", (_) => _, { prefix: "+" }); 53 | const predicate = commandNotFound(cmds); 54 | assert(predicate(ctx)); 55 | }); 56 | it("should return true for customs prefixed registered matching the input", () => { 57 | const cmds = new CommandGroup(); 58 | cmds.command("papacin", "", (_) => _, { prefix: "+" }); 59 | cmds.command("papazote", "", (_) => _, { prefix: "?" }); 60 | const predicate = commandNotFound(cmds); 61 | assert(predicate(ctx)); 62 | }); 63 | }); 64 | describe("ctx.commandSuggestion", () => { 65 | type withSuggestion = 66 | & CommandsFlavor 67 | & { commandSuggestion: string | null }; 68 | 69 | const cmds = new CommandGroup(); 70 | cmds.command("papazote", "", (_) => _); 71 | cmds.command("papacin", "", (_) => _, { prefix: "+" }); 72 | const predicate = commandNotFound(cmds); 73 | 74 | it("should contain the proper suggestion ", () => { 75 | const ctx = dummyCtx({ userInput: "/papacin" }) as withSuggestion; 76 | predicate(ctx); 77 | assertEquals(ctx.commandSuggestion, "+papacin"); 78 | }); 79 | it("should be null when the input does not match a suggestion", () => { 80 | const ctx = dummyCtx({ 81 | userInput: "/nonadapapi", 82 | }) as withSuggestion; 83 | predicate(ctx); 84 | assertEquals(ctx.commandSuggestion, null); 85 | }); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /test/set-bot-commands.test.ts: -------------------------------------------------------------------------------- 1 | import { resolvesNext } from "https://deno.land/std@0.203.0/testing/mock.ts"; 2 | import { CommandGroup } from "../src/mod.ts"; 3 | import { setBotCommands } from "../src/utils/set-bot-commands.ts"; 4 | import { 5 | Api, 6 | assertRejects, 7 | assertSpyCall, 8 | describe, 9 | it, 10 | spy, 11 | } from "./deps.test.ts"; 12 | 13 | describe("setBotCommands", () => { 14 | describe("when there are invalid commands", () => { 15 | const myCommands = new CommandGroup(); 16 | myCommands.command("Command", "_", (_, next) => next()); // Uppercase letters 17 | myCommands.command("/command", "_", (_, next) => next()); // Invalid character 18 | myCommands.command( 19 | "longcommandlongcommandlongcommand", 20 | "_", 21 | (_, next) => next(), 22 | ); // Too long 23 | myCommands.command("command", "_", (_, next) => next()); // Valid 24 | 25 | describe("when ignoreUncompliantCommands is true", () => { 26 | it("should call api with valid", async () => { 27 | const setMyCommandsSpy = spy(resolvesNext([true] as const)); 28 | 29 | const { scopes, uncompliantCommands } = myCommands.toArgs(); 30 | 31 | await setBotCommands( 32 | { 33 | raw: { setMyCommands: setMyCommandsSpy }, 34 | } as unknown as Api, 35 | scopes, 36 | uncompliantCommands, 37 | { 38 | ignoreUncompliantCommands: true, 39 | }, 40 | ); 41 | 42 | assertSpyCall(setMyCommandsSpy, 0, { 43 | args: [ 44 | { 45 | scope: { type: "default" }, 46 | language_code: undefined, 47 | commands: scopes.map((scope) => scope.commands) 48 | .flat(), 49 | }, 50 | ], 51 | }); 52 | }); 53 | }); 54 | 55 | describe("when ignoreUncompliantCommands is false", () => { 56 | it("should throw", () => { 57 | const { scopes, uncompliantCommands } = myCommands.toArgs(); 58 | assertRejects(() => 59 | setBotCommands({} as any, scopes, uncompliantCommands, { 60 | ignoreUncompliantCommands: false, 61 | }) 62 | ); 63 | }); 64 | }); 65 | 66 | describe("when ignoreUncompliantCommands is undefined", () => { 67 | it("should throw", () => { 68 | const { scopes, uncompliantCommands } = myCommands.toArgs(); 69 | assertRejects(() => 70 | setBotCommands({} as any, scopes, uncompliantCommands) 71 | ); 72 | }); 73 | }); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /test/utils-test.test.ts: -------------------------------------------------------------------------------- 1 | import { isMiddleware } from "../src/utils/checks.ts"; 2 | import { assert, describe, it } from "./deps.test.ts"; 3 | import { Composer } from "../src/deps.deno.ts"; 4 | 5 | describe("Utils tests", () => { 6 | describe("isMiddleware", () => { 7 | it("Composer", () => { 8 | const composer = new Composer(); 9 | assert(isMiddleware(composer)); 10 | }); 11 | it("Composer[]", () => { 12 | const a = new Composer(); 13 | const b = new Composer(); 14 | assert(isMiddleware([a, b])); 15 | }); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2019", 4 | "module": "commonjs", 5 | "esModuleInterop": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "strict": true, 8 | "skipLibCheck": true, 9 | "outDir": "out", 10 | "declaration": true 11 | }, 12 | "include": ["src"] 13 | } 14 | --------------------------------------------------------------------------------