diff --git a/.dockerignore b/.dockerignore index 7f88ebd..457104b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,3 @@ -node_modules -docs +node_modules +docs README.md \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index e6bb422..cd32a3d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,6 +6,8 @@ "popconfirm", "readcursor", "ringbuffer", + "subc", + "subcg", "submod", "turingbot", "unlogged" diff --git a/Dockerfile b/Dockerfile index 6f271a8..18eeb85 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,5 +15,5 @@ COPY config.jsonc ./config.jsonc COPY Makefile ./Makefile COPY target/ ./target/ -# Run it -CMD ["make", "profile"] \ No newline at end of file +# Run it without compiling +CMD ["make", "run"] \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index fe57505..3e3de00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,7 @@ "requires": true, "packages": { "": { + "name": "TuringBot", "version": "0.1.0", "dependencies": { "bufferutil": "^4.0.7", @@ -32,6 +33,15 @@ "node": ">=16.9.0" } }, + "node_modules/@aashutoshrathi/word-wrap": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", + "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/@ampproject/remapping": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", @@ -46,47 +56,47 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.21.4", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.21.4.tgz", - "integrity": "sha512-LYvhNKfwWSPpocw8GI7gpK2nq3HSDuEPC/uSYaALSJu9xjsalaaYFOq0Pwt5KmVqwEbZlDu81aLXwBOmD/Fv9g==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.5.tgz", + "integrity": "sha512-Xmwn266vad+6DAqEB2A6V/CcZVp62BbwVmcOJc2RPuwih1kw02TjQvWVWlcKGbBPd+8/0V5DEkOcizRGYsspYQ==", "dev": true, "dependencies": { - "@babel/highlight": "^7.18.6" + "@babel/highlight": "^7.22.5" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/compat-data": { - "version": "7.22.3", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.22.3.tgz", - "integrity": "sha512-aNtko9OPOwVESUFp3MZfD8Uzxl7JzSeJpd7npIoxCasU37PFbAQRpKglkaKwlHOyeJdrREpo8TW8ldrkYWwvIQ==", + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.22.6.tgz", + "integrity": "sha512-29tfsWTq2Ftu7MXmimyC0C5FDZv5DYxOZkh3XD3+QW4V/BYuv/LyEsjj3c0hqedEaDt6DBfDvexMKU8YevdqFg==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.22.1", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.22.1.tgz", - "integrity": "sha512-Hkqu7J4ynysSXxmAahpN1jjRwVJ+NdpraFLIWflgjpVob3KNyK3/tIUc7Q7szed8WMp0JNa7Qtd1E9Oo22F9gA==", + "version": "7.22.8", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.22.8.tgz", + "integrity": "sha512-75+KxFB4CZqYRXjx4NlR4J7yGvKumBuZTmV4NV6v09dVXXkuYVYLT68N6HCzLvfJ+fWCxQsntNzKwwIXL4bHnw==", "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.21.4", - "@babel/generator": "^7.22.0", - "@babel/helper-compilation-targets": "^7.22.1", - "@babel/helper-module-transforms": "^7.22.1", - "@babel/helpers": "^7.22.0", - "@babel/parser": "^7.22.0", - "@babel/template": "^7.21.9", - "@babel/traverse": "^7.22.1", - "@babel/types": "^7.22.0", + "@babel/code-frame": "^7.22.5", + "@babel/generator": "^7.22.7", + "@babel/helper-compilation-targets": "^7.22.6", + "@babel/helper-module-transforms": "^7.22.5", + "@babel/helpers": "^7.22.6", + "@babel/parser": "^7.22.7", + "@babel/template": "^7.22.5", + "@babel/traverse": "^7.22.8", + "@babel/types": "^7.22.5", + "@nicolo-ribaudo/semver-v6": "^6.3.3", "convert-source-map": "^1.7.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", - "json5": "^2.2.2", - "semver": "^6.3.0" + "json5": "^2.2.2" }, "engines": { "node": ">=6.9.0" @@ -102,22 +112,13 @@ "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "dev": true }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/generator": { - "version": "7.22.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.22.3.tgz", - "integrity": "sha512-C17MW4wlk//ES/CJDL51kPNwl+qiBQyN7b9SKyVp11BLGFeSPoVaHrv+MNt8jwQFhQWowW88z1eeBx3pFz9v8A==", + "version": "7.22.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.22.7.tgz", + "integrity": "sha512-p+jPjMG+SI8yvIaxGgeW24u7q9+5+TGpZh8/CuB7RhBKd7RCy8FayNEFNNKrNK/eUcY/4ExQqLmyrvBXKsIcwQ==", "dev": true, "dependencies": { - "@babel/types": "^7.22.3", + "@babel/types": "^7.22.5", "@jridgewell/gen-mapping": "^0.3.2", "@jridgewell/trace-mapping": "^0.3.17", "jsesc": "^2.5.1" @@ -137,16 +138,16 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.22.1", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.1.tgz", - "integrity": "sha512-Rqx13UM3yVB5q0D/KwQ8+SPfX/+Rnsy1Lw1k/UwOC4KC6qrzIQoY3lYnBu5EHKBlEHHcj0M0W8ltPSkD8rqfsQ==", + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.6.tgz", + "integrity": "sha512-534sYEqWD9VfUm3IPn2SLcH4Q3P86XL+QvqdC7ZsFrzyyPF3T4XGiVghF6PTYNdWg6pXuoqXxNQAhbYeEInTzA==", "dev": true, "dependencies": { - "@babel/compat-data": "^7.22.0", - "@babel/helper-validator-option": "^7.21.0", - "browserslist": "^4.21.3", - "lru-cache": "^5.1.1", - "semver": "^6.3.0" + "@babel/compat-data": "^7.22.6", + "@babel/helper-validator-option": "^7.22.5", + "@nicolo-ribaudo/semver-v6": "^6.3.3", + "browserslist": "^4.21.9", + "lru-cache": "^5.1.1" }, "engines": { "node": ">=6.9.0" @@ -164,15 +165,6 @@ "yallist": "^3.0.2" } }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/helper-compilation-targets/node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -180,142 +172,142 @@ "dev": true }, "node_modules/@babel/helper-environment-visitor": { - "version": "7.22.1", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.1.tgz", - "integrity": "sha512-Z2tgopurB/kTbidvzeBrc2To3PUP/9i5MUe+fU6QJCQDyPwSH2oRapkLw3KGECDYSjhQZCNxEvNvZlLw8JjGwA==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.5.tgz", + "integrity": "sha512-XGmhECfVA/5sAt+H+xpSg0mfrHq6FzNr9Oxh7PSEBBRUb/mL7Kz3NICXb194rCqAEdxkhPT1a88teizAFyvk8Q==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-function-name": { - "version": "7.21.0", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.21.0.tgz", - "integrity": "sha512-HfK1aMRanKHpxemaY2gqBmL04iAPOPRj7DxtNbiDOrJK+gdwkiNRVpCpUJYbUT+aZyemKN8brqTOxzCaG6ExRg==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.22.5.tgz", + "integrity": "sha512-wtHSq6jMRE3uF2otvfuD3DIvVhOsSNshQl0Qrd7qC9oQJzHvOL4qQXlQn2916+CXGywIjpGuIkoyZRRxHPiNQQ==", "dev": true, "dependencies": { - "@babel/template": "^7.20.7", - "@babel/types": "^7.21.0" + "@babel/template": "^7.22.5", + "@babel/types": "^7.22.5" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-hoist-variables": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz", - "integrity": "sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", "dev": true, "dependencies": { - "@babel/types": "^7.18.6" + "@babel/types": "^7.22.5" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.21.4", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.21.4.tgz", - "integrity": "sha512-orajc5T2PsRYUN3ZryCEFeMDYwyw09c/pZeaQEZPH0MpKzSvn3e0uXsDBu3k03VI+9DBiRo+l22BfKTpKwa/Wg==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.5.tgz", + "integrity": "sha512-8Dl6+HD/cKifutF5qGd/8ZJi84QeAKh+CEe1sBzz8UayBBGg1dAIJrdHOcOM5b2MpzWL2yuotJTtGjETq0qjXg==", "dev": true, "dependencies": { - "@babel/types": "^7.21.4" + "@babel/types": "^7.22.5" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.22.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.22.1.tgz", - "integrity": "sha512-dxAe9E7ySDGbQdCVOY/4+UcD8M9ZFqZcZhSPsPacvCG4M+9lwtDDQfI2EoaSvmf7W/8yCBkGU0m7Pvt1ru3UZw==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.22.5.tgz", + "integrity": "sha512-+hGKDt/Ze8GFExiVHno/2dvG5IdstpzCq0y4Qc9OJ25D4q3pKfiIP/4Vp3/JvhDkLKsDK2api3q3fpIgiIF5bw==", "dev": true, "dependencies": { - "@babel/helper-environment-visitor": "^7.22.1", - "@babel/helper-module-imports": "^7.21.4", - "@babel/helper-simple-access": "^7.21.5", - "@babel/helper-split-export-declaration": "^7.18.6", - "@babel/helper-validator-identifier": "^7.19.1", - "@babel/template": "^7.21.9", - "@babel/traverse": "^7.22.1", - "@babel/types": "^7.22.0" + "@babel/helper-environment-visitor": "^7.22.5", + "@babel/helper-module-imports": "^7.22.5", + "@babel/helper-simple-access": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.5", + "@babel/helper-validator-identifier": "^7.22.5", + "@babel/template": "^7.22.5", + "@babel/traverse": "^7.22.5", + "@babel/types": "^7.22.5" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-simple-access": { - "version": "7.21.5", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.21.5.tgz", - "integrity": "sha512-ENPDAMC1wAjR0uaCUwliBdiSl1KBJAVnMTzXqi64c2MG8MPR6ii4qf7bSXDqSFbr4W6W028/rf5ivoHop5/mkg==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", + "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", "dev": true, "dependencies": { - "@babel/types": "^7.21.5" + "@babel/types": "^7.22.5" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-split-export-declaration": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz", - "integrity": "sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==", + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", + "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", "dev": true, "dependencies": { - "@babel/types": "^7.18.6" + "@babel/types": "^7.22.5" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.21.5", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.21.5.tgz", - "integrity": "sha512-5pTUx3hAJaZIdW99sJ6ZUUgWq/Y+Hja7TowEnLNMm1VivRgZQL3vpBY3qUACVsvw+yQU6+YgfBVmcbLaZtrA1w==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz", + "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.19.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz", - "integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.5.tgz", + "integrity": "sha512-aJXu+6lErq8ltp+JhkJUfk1MTGyuA4v7f3pA+BJ5HLfNC6nAQ0Cpi9uOquUj8Hehg0aUiHzWQbOVJGao6ztBAQ==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.21.0", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.21.0.tgz", - "integrity": "sha512-rmL/B8/f0mKS2baE9ZpyTcTavvEuWhTTW8amjzXNvYG4AwBsqTLikfXsEofsJEfKHf+HQVQbFOHy6o+4cnC/fQ==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.22.5.tgz", + "integrity": "sha512-R3oB6xlIVKUnxNUxbmgq7pKjxpru24zlimpE8WK47fACIlM0II/Hm1RS8IaOI7NgCr6LNS+jl5l75m20npAziw==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.22.3", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.22.3.tgz", - "integrity": "sha512-jBJ7jWblbgr7r6wYZHMdIqKc73ycaTcCaWRq4/2LpuPHcx7xMlZvpGQkOYc9HeSjn6rcx15CPlgVcBtZ4WZJ2w==", + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.22.6.tgz", + "integrity": "sha512-YjDs6y/fVOYFV8hAf1rxd1QvR9wJe1pDBZ2AREKq/SDayfPzgk0PBnVuTCE5X1acEpMMNOVUqoe+OwiZGJ+OaA==", "dev": true, "dependencies": { - "@babel/template": "^7.21.9", - "@babel/traverse": "^7.22.1", - "@babel/types": "^7.22.3" + "@babel/template": "^7.22.5", + "@babel/traverse": "^7.22.6", + "@babel/types": "^7.22.5" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/highlight": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz", - "integrity": "sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.5.tgz", + "integrity": "sha512-BSKlD1hgnedS5XRnGOljZawtag7H1yPfQp0tdNJCHoH6AZ+Pcm9VvkrK59/Yy593Ypg0zMxH2BxD1VPYUQ7UIw==", "dev": true, "dependencies": { - "@babel/helper-validator-identifier": "^7.18.6", + "@babel/helper-validator-identifier": "^7.22.5", "chalk": "^2.0.0", "js-tokens": "^4.0.0" }, @@ -380,9 +372,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.22.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.4.tgz", - "integrity": "sha512-VLLsx06XkEYqBtE5YGPwfSGwfrjnyPP5oiGty3S8pQLFDFLaS8VwWSIxkTXpcvr5zeYLE6+MBNl2npl/YnfofA==", + "version": "7.22.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.7.tgz", + "integrity": "sha512-7NF8pOkHP5o2vpmGgNGcfAeCvOYhGLyA3Z4eBQkT1RJlWu47n63bCs93QfJ2hIAFCil7L5P2IWhs1oToVgrL0Q==", "dev": true, "bin": { "parser": "bin/babel-parser.js" @@ -404,33 +396,33 @@ } }, "node_modules/@babel/template": { - "version": "7.21.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.21.9.tgz", - "integrity": "sha512-MK0X5k8NKOuWRamiEfc3KEJiHMTkGZNUjzMipqCGDDc6ijRl/B7RGSKVGncu4Ro/HdyzzY6cmoXuKI2Gffk7vQ==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.5.tgz", + "integrity": "sha512-X7yV7eiwAxdj9k94NEylvbVHLiVG1nvzCV2EAowhxLTwODV1jl9UzZ48leOC0sH7OnuHrIkllaBgneUykIcZaw==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.21.4", - "@babel/parser": "^7.21.9", - "@babel/types": "^7.21.5" + "@babel/code-frame": "^7.22.5", + "@babel/parser": "^7.22.5", + "@babel/types": "^7.22.5" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.22.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.22.4.tgz", - "integrity": "sha512-Tn1pDsjIcI+JcLKq1AVlZEr4226gpuAQTsLMorsYg9tuS/kG7nuwwJ4AB8jfQuEgb/COBwR/DqJxmoiYFu5/rQ==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.21.4", - "@babel/generator": "^7.22.3", - "@babel/helper-environment-visitor": "^7.22.1", - "@babel/helper-function-name": "^7.21.0", - "@babel/helper-hoist-variables": "^7.18.6", - "@babel/helper-split-export-declaration": "^7.18.6", - "@babel/parser": "^7.22.4", - "@babel/types": "^7.22.4", + "version": "7.22.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.22.8.tgz", + "integrity": "sha512-y6LPR+wpM2I3qJrsheCTwhIinzkETbplIgPBbwvqPKc+uljeA5gP+3nP8irdYt1mjQaDnlIcG+dw8OjAco4GXw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.22.5", + "@babel/generator": "^7.22.7", + "@babel/helper-environment-visitor": "^7.22.5", + "@babel/helper-function-name": "^7.22.5", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.22.7", + "@babel/types": "^7.22.5", "debug": "^4.1.0", "globals": "^11.1.0" }, @@ -439,13 +431,13 @@ } }, "node_modules/@babel/types": { - "version": "7.22.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.22.4.tgz", - "integrity": "sha512-Tx9x3UBHTTsMSW85WB2kphxYQVvrZ/t1FxD88IpSgIjiUJlCm9z+xWIDwyo1vffTwSqteqyznB8ZE9vYYk16zA==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.22.5.tgz", + "integrity": "sha512-zo3MIHGOkPOfoRXitsgHLjEXmlDaD/5KU1Uzuc9GNiZPhSqVxVRtxuPaSBZDsYZ9qV88AjtMtWW7ww98loJ9KA==", "dev": true, "dependencies": { - "@babel/helper-string-parser": "^7.21.5", - "@babel/helper-validator-identifier": "^7.19.1", + "@babel/helper-string-parser": "^7.22.5", + "@babel/helper-validator-identifier": "^7.22.5", "to-fast-properties": "^2.0.0" }, "engines": { @@ -706,6 +698,15 @@ "node": ">=v12.0.0" } }, + "node_modules/@nicolo-ribaudo/semver-v6": { + "version": "6.3.3", + "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/semver-v6/-/semver-v6-6.3.3.tgz", + "integrity": "sha512-3Yc1fUTs69MG/uZbJlLSI3JISMn2UV2rg+1D/vROUqZyh3l6iYHCs7GMp+M40ZD7yOdDbYjJcU1oTJhrc+dGKg==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1736,9 +1737,9 @@ } }, "node_modules/browserslist": { - "version": "4.21.7", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.7.tgz", - "integrity": "sha512-BauCXrQ7I2ftSqd2mvKHGo85XR0u7Ru3C/Hxsy/0TkfCtjrmAbPdzLGasmoiBxplpDXlPvdjX9u7srIMfgasNA==", + "version": "4.21.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.9.tgz", + "integrity": "sha512-M0MFoZzbUrRU4KNfCrDLnvyE7gub+peetoTid3TBIqtunaDJyXlwhakT+/VkvSXcfIzFfK/nkCs4nmyTmxdNSg==", "dev": true, "funding": [ { @@ -1755,8 +1756,8 @@ } ], "dependencies": { - "caniuse-lite": "^1.0.30001489", - "electron-to-chromium": "^1.4.411", + "caniuse-lite": "^1.0.30001503", + "electron-to-chromium": "^1.4.431", "node-releases": "^2.0.12", "update-browserslist-db": "^1.0.11" }, @@ -1926,9 +1927,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001491", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001491.tgz", - "integrity": "sha512-17EYIi4TLnPiTzVKMveIxU5ETlxbSO3B6iPvMbprqnKh4qJsQGk5Nh1Lp4jIMAE0XfrujsJuWZAM3oJdMHaKBA==", + "version": "1.0.30001514", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001514.tgz", + "integrity": "sha512-ENcIpYBmwAAOm/V2cXgM7rZUrKKaqisZl4ZAI520FIkqGXUxJjmaIssbRW5HVVR5tyV6ygTLIm15aU8LUmQSaQ==", "dev": true, "funding": [ { @@ -2835,9 +2836,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.414", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.414.tgz", - "integrity": "sha512-RRuCvP6ekngVh2SAJaOKT/hxqc9JAsK+Pe0hP5tGQIfonU2Zy9gMGdJ+mBdyl/vNucMG6gkXYtuM4H/1giws5w==", + "version": "1.4.454", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.454.tgz", + "integrity": "sha512-pmf1rbAStw8UEQ0sr2cdJtWl48ZMuPD9Sto8HVQOq9vx9j2WgDEN6lYoaqFvqEHYOmGA9oRGn7LqWI9ta0YugQ==", "dev": true }, "node_modules/elliptic": { @@ -5590,9 +5591,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.12", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.12.tgz", - "integrity": "sha512-QzsYKWhXTWx8h1kIvqfnC++o0pEmpRQA/aenALsL2F4pqNVr7YzcdMlDij5WBnwftRbJCNJL/O7zdKaxKPHqgQ==", + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz", + "integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==", "dev": true }, "node_modules/normalize-html-whitespace": { @@ -5695,17 +5696,17 @@ } }, "node_modules/optionator": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", - "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", + "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", "dev": true, "dependencies": { + "@aashutoshrathi/word-wrap": "^1.2.3", "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.3" + "type-check": "^0.4.0" }, "engines": { "node": ">= 0.8.0" @@ -6904,9 +6905,9 @@ } }, "node_modules/semver": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.1.tgz", - "integrity": "sha512-Wvss5ivl8TMRZXXESstBA4uR5iXgEN/VC5/sOcuXdVLzcdkz4HWetIoRfG5gb5X+ij/G9rw9YoGn3QoQ8OCSpw==", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dev": true, "dependencies": { "lru-cache": "^6.0.0" @@ -8177,15 +8178,6 @@ "node": ">=0.4.0" } }, - "node_modules/word-wrap": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", - "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/wordwrap": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz", diff --git a/src/core/discord.ts b/src/core/discord.ts index 95a5ba9..d33425a 100644 --- a/src/core/discord.ts +++ b/src/core/discord.ts @@ -1,15 +1,38 @@ /** * @file - * This file contains utilities and abstractions to make interacting with discord easier and more convenient. + * This file contains utilities and abstractions to make interacting with discord easier */ import { + APIApplicationCommandOptionChoice, APIEmbed, ActionRowBuilder, + ApplicationCommand, ButtonBuilder, ButtonStyle, + ChatInputCommandInteraction, + Collection, Message, + REST, + RESTPostAPIChatInputApplicationCommandsJSONBody, + Routes, + SlashCommandAttachmentOption, + SlashCommandBooleanOption, + SlashCommandBuilder, + SlashCommandChannelOption, + SlashCommandIntegerOption, + SlashCommandMentionableOption, + SlashCommandNumberOption, + SlashCommandRoleOption, + SlashCommandStringOption, + SlashCommandSubcommandBuilder, + SlashCommandUserOption, } from 'discord.js'; +import {client, guild, modules} from './main.js'; +import {ModuleInputOption, ModuleOptionType, RootModule} from './modules.js'; +import {botConfig} from './config.js'; +import {EventCategory, logEvent} from './logger.js'; + /** * Used in pairing with `{@link confirmEmbed()}`, this is a way to indicate whether or not the user confirmed a choice, and is passed as * the contents of the Promise returned by `{@link confirmEmbed()}`. @@ -19,6 +42,17 @@ export enum ConfirmEmbedResponse { Denied = 'denied', } +type SlashCommandOption = + | SlashCommandAttachmentOption + | SlashCommandBooleanOption + | SlashCommandChannelOption + | SlashCommandIntegerOption + | SlashCommandMentionableOption + | SlashCommandNumberOption + | SlashCommandRoleOption + | SlashCommandStringOption + | SlashCommandUserOption; + /** * Helper utilities used to speed up embed work */ @@ -79,7 +113,8 @@ export const embed = { */ async confirmEmbed( prompt: string, - message: Message, + // this might break if reply() is called twice + message: Message | ChatInputCommandInteraction, timeout = 60 ): Promise { // https://discordjs.guide/message-components/action-rows.html @@ -104,11 +139,10 @@ export const embed = { // listen for a button interaction try { const interaction = await response.awaitMessageComponent({ - filter: i => i.user.id === message.author.id, + filter: i => i.user.id === message.member?.user.id, time: timeout * 1000, }); response.delete(); - // the custom id is set with the enum values, so we can pass that transparently without worrying about it being invalid return interaction.customId as ConfirmEmbedResponse; } catch { // awaitMessageComponent throws an error when the timeout was reached, so this behavior assumes @@ -129,3 +163,223 @@ export const embed = { } }, }; + +/** + * Obtain a list of all commands in {@link modules} that have not been registered yet + * @throws Will throw an error if the discord client has not been instantiated + */ +export async function getUnregisteredSlashCommands(): Promise { + if (!client.isReady) { + throw new Error( + 'Attempt made to get slash commands before client was initialized' + ); + } + /** A list of every root module without a slash command registered */ + const unregisteredSlashCommands: RootModule[] = []; + /** A discord.js collection of every command registered */ + const allSlashCommands: Collection = + await guild.commands.fetch(); + for (const module of modules) { + /** This value is either undefined, or the thing find() found */ + const searchResult = allSlashCommands.find( + slashCommand => slashCommand.name === module.name + ); + // if it's undefined, than assume a slash command was not registered for that module + if (searchResult === undefined) { + unregisteredSlashCommands.push(module); + } + } + return unregisteredSlashCommands; +} + +// there's a lot of deep nesting and misdirection going on down here, this could probably be greatly improved +/** + * Register a root module as a [discord slash command](https://discordjs.guide/creating-your-bot/command-deployment.html#guild-commands) + * @param module The root module to register as a slash command. + * All subcommands will also be registered + */ +export async function generateSlashCommandForModule( + module: RootModule +): Promise { + // translate the module to slash command form + const slashCommand = new SlashCommandBuilder() + .setName(module.name) + .setDescription(module.description); + + // if the module has submodules, than register those as subcommands + // https://discord.com/developers/docs/interactions/application-commands#subcommands-and-subcommand-groups + // Commands can only be nested 3 layers deep, so command -> subcommand group -> subcommand + for (const submodule of module.submodules) { + // If a submodule has submodules, than it should be treated as a subcommand group. + if (submodule.submodules.length > 0) { + slashCommand.addSubcommandGroup(subcg => { + // apparently this all needs to be set inside of this callback to work? + subcg.setName(submodule.name).setDescription(submodule.description); + const submodulesInGroup = submodule.submodules; + for (const submoduleInGroup of submodulesInGroup) { + // options may need to be added inside of the addSubcommand block + subcg.addSubcommand(subc => { + subc + .setName(submoduleInGroup.name) + .setDescription(submoduleInGroup.description); + for (const option of submoduleInGroup.options) { + addOptionToCommand(subc, option); + } + return subc; + }); + } + return subcg; + }); + } + // if a submodule does not have submodules, it is treated as an executable subcommand instead of a group + else { + slashCommand.addSubcommand(subcommand => { + subcommand.setName(submodule.name); + subcommand.setDescription(submodule.description); + for (const option of submodule.options) { + addOptionToCommand(subcommand, option); + } + return subcommand; + }); + } + } + return slashCommand; +} +/** TODO: fill out docs */ +function addOptionToCommand( + command: SlashCommandBuilder | SlashCommandSubcommandBuilder, + option: ModuleInputOption +): void { + switch (option.type) { + case ModuleOptionType.Attachment: + command.addAttachmentOption( + newOption => + setOptionFieldsForCommand( + newOption, + option + ) as SlashCommandAttachmentOption + ); + break; + case ModuleOptionType.Boolean: + command.addBooleanOption( + newOption => + setOptionFieldsForCommand( + newOption, + option + ) as SlashCommandBooleanOption + ); + break; + case ModuleOptionType.Channel: + command.addChannelOption( + newOption => + setOptionFieldsForCommand( + newOption, + option + ) as SlashCommandChannelOption + ); + break; + case ModuleOptionType.Integer: + command.addIntegerOption( + newOption => + setOptionFieldsForCommand( + newOption, + option + ) as SlashCommandIntegerOption + ); + break; + case ModuleOptionType.Mentionable: + command.addMentionableOption( + newOption => + setOptionFieldsForCommand( + newOption, + option + ) as SlashCommandMentionableOption + ); + break; + case ModuleOptionType.Number: + command.addNumberOption( + newOption => + setOptionFieldsForCommand( + newOption, + option + ) as SlashCommandNumberOption + ); + break; + case ModuleOptionType.Role: + command.addRoleOption( + newOption => + setOptionFieldsForCommand(newOption, option) as SlashCommandRoleOption + ); + break; + case ModuleOptionType.String: + command.addStringOption( + newOption => + setOptionFieldsForCommand( + newOption, + option + ) as SlashCommandStringOption + ); + break; + case ModuleOptionType.User: + command.addUserOption( + newOption => + setOptionFieldsForCommand(newOption, option) as SlashCommandUserOption + ); + break; + } +} + +/** + * Set the name, description, and whether or not the option is required + * @param option The option to set fields on + * @param setFromModuleOption The {@link ModuleInputOption} to read from + */ +function setOptionFieldsForCommand( + option: SlashCommandOption, + setFromModuleOption: ModuleInputOption +) { + // TODO: length and regex validation for the name and description fields, + // so that you can return a concise error + option + .setName(setFromModuleOption.name) + .setDescription(setFromModuleOption.description) + .setRequired(setFromModuleOption.required ?? false); + if ( + setFromModuleOption.choices !== undefined && + [ + ModuleOptionType.Integer, + ModuleOptionType.Number, + ModuleOptionType.String, + ].includes(setFromModuleOption.type) + ) { + // this could be integer, number, or string + (option as SlashCommandStringOption).addChoices( + ...(setFromModuleOption.choices! as APIApplicationCommandOptionChoice[]) + ); + } + return option; +} + +/** Register the passed list of slash commands to discord, completely overwriting the previous version. There is no way to register a single new slash command. */ +// TODO: maybe there is (https://discord.com/developers/docs/interactions/application-commands#create-guild-application-command) +export async function registerSlashCommandSet( + commandSet: SlashCommandBuilder[] +) { + // ship the provided list off to discord to discord + // https://discordjs.guide/creating-your-bot/command-deployment.html#guild-commands + const rest = new REST().setToken(botConfig.authToken); + /** list of slash commands, converted to json, to be sent off to discord */ + const commands: RESTPostAPIChatInputApplicationCommandsJSONBody[] = []; + for (const command of commandSet) { + commands.push(command.toJSON()); + } + // send everything to discord + // The put method is used to fully refresh all commands in the guild with the current set + await rest.put( + Routes.applicationGuildCommands(botConfig.applicationId, guild.id), + { + body: commands, + } + ); + logEvent(EventCategory.Info, 'core', 'Slash commands refreshed.', 2); +} diff --git a/src/core/main.ts b/src/core/main.ts index df089cc..b18784d 100644 --- a/src/core/main.ts +++ b/src/core/main.ts @@ -6,12 +6,17 @@ import { GatewayIntentBits, APIEmbed, Guild, + ChatInputCommandInteraction, } from 'discord.js'; import {botConfig} from './config.js'; import {EventCategory, logEvent} from './logger.js'; import {RootModule, SubModule} from './modules.js'; -import {embed} from './discord.js'; +import { + embed, + generateSlashCommandForModule, + registerSlashCommandSet, +} from './discord.js'; import path from 'path'; import {fileURLToPath} from 'url'; @@ -39,7 +44,7 @@ export const client = new Client({ // non-null assertion: if the bot isn't in a server, than throwing an error can be considered reasonable behavior export let guild: Guild = botConfig.readConfigFromFileSystem(); -const modules: RootModule[] = []; +export const modules: RootModule[] = []; // This allows catching and handling of async errors, which would normally fully error process.on('unhandledRejection', (error: Error) => { @@ -56,6 +61,7 @@ client.once(Events.ClientReady, async () => { guild = client.guilds.cache.first()!; logEvent(EventCategory.Info, 'core', 'Initialized Discord connection', 2); + // TODO: move module imports to a function // annoyingly, readdir() calls are relative to the node process, not the file making the call, // so it's resolved manually to make this more robust const moduleLocation = fileURLToPath( @@ -86,10 +92,26 @@ client.once(Events.ClientReady, async () => { } } + const newSlashCommands = []; + for (const module of modules) { + newSlashCommands.push(generateSlashCommandForModule(module)); + } + // TODO: unregister slash commands if disabled, and detect any changes to slash commands + await registerSlashCommandSet(await Promise.all(newSlashCommands)); await initializeModules(); - listen(); + // TODO: only listen if a prefix, or slash commands enabled + listenForSlashCommands(); + logEvent( + EventCategory.Info, + 'core', + 'Initialization completed, ready to receive commands.', + 2 + ); }); +// Login to discord +client.login(botConfig.authToken); + /** * This function imports the default export from the file specified, and pushes each module to * {@link modules} @@ -111,147 +133,180 @@ async function importModulesFromFile(path: string): Promise { } /** - * Start a listener that checks to see if a user called a command, and then check - * `modules` for the appropriate code to run - */ -function listen(): void { - client.on(Events.MessageCreate, async message => { - // Check to see if the message starts with the correct prefix and wasn't sent by the bot (test user aside) - if (message.author.bot && message.author.id !== botConfig.testing.userID) + * Start an event listener that executes received slash commands + * https://discordjs.guide/creating-your-bot/command-handling.html#receiving-command-interactions */ +function listenForSlashCommands() { + client.on(Events.InteractionCreate, interaction => { + if (!interaction.isChatInputCommand()) { return; - if (!botConfig.prefixes.includes(message.content.charAt(0))) return; + } + const command = interaction.commandName; + const group = interaction.options.getSubcommandGroup(); + const subcommand = interaction.options.getSubcommand(); - // message content split by spaces, with the prefix removed from the first item - const tokens = message.content.split(' '); - tokens[0] = tokens[0].substring(1); + interaction.reply({content: "everything is broken and i'm dying"}); + const commandPath: string[] = []; + commandPath.push(command); + if (group !== null) { + commandPath.push(group); + } + if (subcommand !== null) { + commandPath.push(subcommand); + } + const resolutionResult = resolveModule(commandPath); + console.log(resolutionResult); + if (resolutionResult.foundModule !== null) { + executeModule(resolutionResult.foundModule, interaction); + } + }); +} - /* - * If tokens[0] a valid reference to the top level of any module in modules, - * set currentMod to the matching module. - * Check tokens[1] against all submodules of currentMod. - * If match, increment token checker and set currentMod to that submodule - * if no match is found, or a module has no more submodules, attempt to execute that command - */ +interface ModuleResolutionResult { + /** The module found, or null, if no module was found at all */ + foundModule: RootModule | SubModule | null; + /** The list of tokens that points to the module */ + modulePath: string[]; + /** Everything after the module path that is not a part of the path, should be treated as module arguments. */ + leftoverTokens: string[]; +} - /** - * As the command is processed, the command used is dumped here. - */ - const commandUsed: string[] = []; +/** Sort of like a file path, given a list of tokens, look through the modules array and find the module the list points to */ +function resolveModule(tokens: string[]): ModuleResolutionResult { + /* + * If tokens[0] a valid reference to the top level of any module in modules, + * set currentMod to the matching module. + * Check tokens[1] against all submodules of currentMod. + * If match, increment token checker and set currentMod to that submodule + * if no match is found, or a module has no more submodules, attempt to execute that command + */ - // initial check to see if first term refers to a module in the `modules` array - /** - * Try to get a module from the `modules` list with the first token. If it's found, the first token is removed. - * - * @returns Will return nothing if it doesn't find a module, or return the module it found - */ - function getModWithFirstToken(): RootModule | void { - const token = tokens[0].toLowerCase(); - for (const mod of modules) { - if (token === mod.command || mod.aliases.includes(token)) { - commandUsed.push(tokens.shift()!); - return mod; - } + /** + * As the command is processed, every time the next token points to a valid module, it's dumped here + */ + const modulePath: string[] = []; + + // initial check to see if first term refers to a module in the `modules` array + /** + * Try to get a module from the `modules` list with the first token. If it's found, the first token is removed. + * + * @returns Will return nothing if it doesn't find a module, or return the module it found + */ + function getModWithFirstToken(): RootModule | void { + const token = tokens[0].toLowerCase(); + for (const mod of modules) { + if (token === mod.name) { + modulePath.push(tokens.shift()!); + return mod; } } + } - const foundRootModule = getModWithFirstToken(); - if (foundRootModule === undefined) { - return; - } + const foundRootModule = getModWithFirstToken(); + if (foundRootModule === undefined) { + return { + foundModule: null, + modulePath: modulePath, + leftoverTokens: tokens, + }; + } - /** - * This module is recursively set as submodules are searched for - */ - let currentModule: RootModule | SubModule = foundRootModule; + /** + * This module is recursively set as submodules are searched for + */ + let currentModule: RootModule | SubModule = foundRootModule; - // this code will resolve currentModule to the last valid module in the list of tokens, - // removing the first token and adding it to - while (tokens.length > 0) { - // lowercase version of the token used for module resolution - const token = tokens[0].toLowerCase(); - // first check to see if the first token in the list references a module or any of its aliases - for (const mod of currentModule.submodules) { - if (token === mod.command || mod.aliases.includes(token)) { - currentModule = mod; - // remove the first token from tokens - // non-null assertion: this code is only reachable if tokens[0] is set - // the first element in the tokens array is used over `token` because the array preserves case, - // while `token` does not - commandUsed.push(tokens.shift()!); - continue; - } + // this code will resolve currentModule to the last valid module in the list of tokens, + // removing the first token and adding it to + while (tokens.length > 0) { + // lowercase version of the token used for module resolution + const token = tokens[0].toLowerCase(); + let moduleFound = false; + // first check to see if the first token in the list references a module + // The below check is needed because it's apparently a 'design limitation' of typescript. + // https://github.com/microsoft/TypeScript/issues/43047 + // @ts-expect-error 7022 + for (const mod of currentModule.submodules) { + if (token === mod.command) { + currentModule = mod; + // remove the first token from tokens + // non-null assertion: this code is only reachable if tokens[0] is set + // the first element in the tokens array is used over `token` because the array preserves case, + // while `token` does not + modulePath.push(tokens.shift()!); + moduleFound = true; + break; } - // the token doesn't reference a command, move on, stop trying to resolve + } + // the token doesn't reference a command, move on, stop trying to resolve + if (!moduleFound) { break; } + } - // if we've reached this point, then everything in `commandUsed` points to a valid command, - /* - * There are two logical flows that should take place now: - * - Display help message if the last valid found module (currentModule) has submodules (don't execute to prevent unintended behavior) - * - If the last valid found module (currentModule) has *no* submodules, then the user is referencing it as a command, - * and everything after it is a command argument - */ - if (currentModule.submodules.length === 0) { - // TODO: move this to a separate function - // no submodules, it's safe to execute the command and return - // first iterate over all dependencies and resolve them. if resolution fails, then return an error message - for (const dep of currentModule.dependencies) { - const depResult: unknown = await dep.resolve(); - // .resolve() returns null if resolution failed - if (depResult === null) { - void message.reply({ - embeds: [ - embed.errorEmbed( - `Unable to execute command because dependency "${dep.name}" could not be resolved` - ), - ], - }); - return; - } + return { + foundModule: currentModule, + modulePath: modulePath, + leftoverTokens: tokens, + }; +} + +/** Resolve all dependencies for a module, and then execute it, responding to the user with an error if needed */ +async function executeModule( + module: RootModule | SubModule, + interaction: ChatInputCommandInteraction +) { + // TODO: move this to a separate function + // no submodules, it's safe to execute the command and return + // first iterate over all dependencies and resolve them. if resolution fails, then return an error message + for (const dep of module.dependencies) { + const depResult: unknown = await dep.resolve(); + // .resolve() returns null if resolution failed + if (depResult === null) { + void interaction.reply({ + embeds: [ + embed.errorEmbed( + `Unable to execute command because dependency "${dep.name}" could not be resolved` + ), + ], + }); + return; + } + } + // There may be possible minor perf/mem overhead from calling Array.from to un-readonly the array, + // could be considered for minor optimizations + module + .executeCommand(Array.from(interaction.options.data), interaction) + .then((value: void | APIEmbed) => { + // enable modules to return an embed + if (value !== undefined) { + void interaction.reply({embeds: [value!]}); } - currentModule - .executeCommand(tokens.join(' '), message) - .then((value: void | APIEmbed) => { - // enable modules to return an embed - if (value !== undefined) { - void message.reply({embeds: [value!]}); - } - }) - .catch((err: Error) => { - logEvent( - EventCategory.Error, - 'core', - `Encountered an error running command ${currentModule.command}:` + + }) + .catch((err: Error) => { + logEvent( + EventCategory.Error, + 'core', + `Encountered an error running command ${module.name}:` + + '```' + + err.name + + '\n' + + err.stack + + '```', + 3 + ); + void interaction.reply({ + embeds: [ + embed.errorEmbed( + 'Command returned an error:\n' + '```' + err.name + '\n' + err.stack + - '```', - 3 - ); - void message.reply({ - embeds: [ - embed.errorEmbed( - 'Command returned an error:\n' + - '```' + - err.name + - '\n' + - err.stack + - '```' - ), - ], - }); - }); - } else { - // there are submodules, display help message - void message.reply({ - embeds: [ - generateHelpMessageForModule(currentModule, commandUsed.join(' ')), + '```' + ), ], }); - } - }); + }); } /** @@ -261,7 +316,9 @@ function listen(): void { * @param priorCommands If specified, this will format the help message to make the command include these. * So if the user typed `foo bar baz`, and you want to generate a help message, you can make help strings * include the full command + * @deprecated I don't think this is needed anymore with the slash command migration */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars function generateHelpMessageForModule( mod: SubModule | RootModule, priorCommands = '' @@ -272,8 +329,8 @@ function generateHelpMessageForModule( const helpFields: APIEmbedField[] = []; for (const submod of mod.submodules) { helpFields.push({ - name: `\`${priorCommands} ${submod.command}\``, - value: `${submod.helpMessage} \n(${submod.submodules.length} subcommands)`, + name: `\`${priorCommands} ${submod.name}\``, + value: `${submod.description} \n(${submod.submodules.length} subcommands)`, }); } @@ -288,43 +345,51 @@ function generateHelpMessageForModule( * Iterate over the `modules` list and call `initialize()` on each module in the list */ async function initializeModules(): Promise { + // enable concurrent initialization + const initializationJobs: Promise[] = []; for (const mod of modules) { - // TODO: make this more concurrent so that more modules are resolved - // while we wait for dep resolution - // by starting all of the functions at once then awaiting completion, it's considerably more efficient - const jobs: Array> = []; - for (const dependency of mod.dependencies) { - jobs.push(dependency.resolve()); + initializationJobs.push(initializeModule(mod)); + } + await Promise.allSettled(initializationJobs); +} + +/** Given a root module, initialize dependencies and call .initialize(), if the module is enabled. */ +async function initializeModule(module: RootModule): Promise { + if (module.enabled) { + // by starting all of the resolve() calls at once then awaiting completion, it's considerably more efficient + const dependencyJobs: Array> = []; + for (const dependency of module.dependencies) { + dependencyJobs.push(dependency.resolve()); } - const jobResults = await Promise.all(jobs); + const jobResults = await Promise.all(dependencyJobs); + // if resolution failed if (jobResults.includes(null)) { - continue; + return; } - if (mod.enabled) { - logEvent( - EventCategory.Info, - 'core', - `Initializing module: ${mod.command}`, - 3 - ); - mod.initialize().catch(() => { + await module + .initialize() + .then(() => { + logEvent( + EventCategory.Info, + 'core', + `Initialized module: ${module.name}`, + 3 + ); + }) + .catch(() => { logEvent( EventCategory.Error, 'core', - `Module \`${mod.command}\` ran into an error during initialization call. This module will be disabled`, + `Module \`${module.name}\` ran into an error during initialization call. This module will be disabled`, 1 ); }); - } else { - logEvent( - EventCategory.Info, - 'core', - 'Encountered disabled module: ' + mod.command, - 3 - ); - } + } else { + logEvent( + EventCategory.Info, + 'core', + 'Encountered disabled module: ' + module.name, + 3 + ); } } - -// Login to discord -client.login(botConfig.authToken); diff --git a/src/core/modules.ts b/src/core/modules.ts index 9ec6121..67adbf9 100644 --- a/src/core/modules.ts +++ b/src/core/modules.ts @@ -5,7 +5,85 @@ import {EventCategory, logEvent} from './logger.js'; import {botConfig} from './config.js'; -import {APIEmbed, Message} from 'discord.js'; +import { + APIApplicationCommandOptionChoice, + APIEmbed, + ChatInputCommandInteraction, + CommandInteractionOption, +} from 'discord.js'; + +/** + * Possible valid types for a slash command input + * @see {@link https://discord.js.org/docs/packages/builders/stable/SlashCommandBuilder:Class#/docs/builders/main/class/SlashCommandBuilder} + * + * (discord docs) + * + * https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-option-type + * + * Refer to the `.add*Option()` methods + */ +export enum ModuleOptionType { + Attachment, + Boolean, + Channel, + Integer, + Mentionable, + Number, + Role, + String, + User, +} + +/** + * When slash commands are registered, you can have your slash command accept input with an + * [Option](https://discordjs.guide/slash-commands/advanced-creation.html#adding-options). + * Any options specified in the module constructor will be passed to the execution function. + */ +export interface ModuleInputOption { + /** + * The type of input you'd like the option to receive. + * You might want to specify a string, or an integer, + * or any of the other members of {@link ModuleOptionType} + */ + type: ModuleOptionType; + /** + * The `name` field of a slash command option. + * This option is required. + * + * The supplied string **MUST** comply with the following regex: + * + * `/^[-_\p{L}\p{N}\p{sc=Deva}\p{sc=Thai}]{1,32}$/u` + * + * Test different names [here](https://regexr.com/7gu4v). + * + * I'm unsure why Discord requires this, or why discord.js's regex doesn't match + * Discord's, but see [here](https://discord.com/developers/docs/interactions/application-commands#application-command-object) + * for the exact phrasing. + */ + name: string; + /** + * A short message that appears below the name of an option. + */ + description: string; + /** + * If set to true, the user won't be allowed to submit the command unless this option is populated. + * + * Defaults to `false` if not set. + */ + required?: boolean; + /** + * If you want to allow the user to select from up to 25 different predetermined options, + * you can define a list of {@link ApplicationCommandOptionChoiceData}s + * + * Autocomplete *cannot* be set to true if you have defined choices, and this + * only applies for string, integer, and number options + */ + choices?: APIApplicationCommandOptionChoice[]; + /** + * TODO: autocomplete docstring and other thing + * https://discordjs.guide/slash-commands/autocomplete.html#responding-to-autocomplete-interactions + */ +} interface ModuleConfig { enabled: boolean; @@ -13,6 +91,13 @@ interface ModuleConfig { [customProperties: string]: any; } +/** The function run when a command is called. If an embed is returned, it's automatically sent as a response*/ +type ModuleCommandFunction = ( + args: CommandInteractionOption[], + interaction: ChatInputCommandInteraction +) => Promise; + +// TODO: maybe separate help message and usage strings? /** * This allows extension of the bot's initial functionality. Almost all discord facing functionality should be implemented as a module * @param command The key phrase that references this module. There must be an extension config key matching this, or the module will be disabled. @@ -24,17 +109,15 @@ export class BaseModule { /** * The case insensitive name you want to use to trigger the command. If the command was `foo`, you could type the configured prefix, followed by foo */ - readonly command: string; + readonly name: string; /** - * Any alternative phrases you want to trigger the command. If the command was `foobar` you could maybe use `fb` or `f` + * A string under 100 chars that explains your slash command. + * A good description may read something like "Kick a member", + * or "Fetch google search results", where a bad description might be vague, + * and unhelpful, like "The google slash command", or "google". */ - readonly aliases: string[] = []; - - /** - * This message will be displayed when the `help` utility is called, and when a command that has subcommands is referenced - */ - readonly helpMessage: string; + readonly description: string; /** * A list of things needed for this module to run @@ -43,14 +126,16 @@ export class BaseModule { */ dependencies: Dependency[] = []; + /** + * A list of input options that are registered with the slash command, and then passed to the command during execution as args. + */ + options: ModuleInputOption[] = []; + /** * Call this whenever you want to act on a command use. If you're just developing the module, set this with `onCommandExecute()`, and * the actual execution will be handled by the core. */ - executeCommand: ( - args: string | undefined, - msg: Message - ) => Promise = async () => {}; + executeCommand: ModuleCommandFunction = async () => {}; /** * Whether or not the `initialize()` call was completed. If your initialization function never returns, you need to manually @@ -82,14 +167,12 @@ export class BaseModule { constructor( command: string, helpMessage: string, - onCommandExecute?: ( - args: string | undefined, - msg: Message - ) => Promise - // rootModuleName?: string + options?: ModuleInputOption[], + onCommandExecute?: ModuleCommandFunction ) { - this.command = command; - this.helpMessage = helpMessage; + this.name = command; + this.description = helpMessage; + this.options = options ?? []; // the default behavior for this is to do nothing if (this.onCommandExecute) { this.onCommandExecute(onCommandExecute!); @@ -104,12 +187,8 @@ export class BaseModule { * @param functionToCall this function gets passed the args (everything past past the command usage), * and a [message](https://discord.js.org/#/docs/discord.js/main/class/Message) handle */ - onCommandExecute( - functionToCall: ( - args: string | undefined, - msg: Message - ) => Promise - ) { + onCommandExecute(functionToCall: ModuleCommandFunction) { + // This could be used to wrap extra behavior into command execution this.executeCommand = functionToCall; } @@ -128,16 +207,15 @@ export class RootModule extends BaseModule { enabled = false; // TODO: docstrings + // TODO: have one overload for a group, and one overload for the executable version of the command constructor( - command: string, - helpMessage: string, + name: string, + description: string, dependencies: Dependency[], - onCommandExecute?: ( - args: string | undefined, - msg: Message - ) => Promise + options?: ModuleInputOption[], + onCommandExecute?: ModuleCommandFunction ) { - super(command, helpMessage, onCommandExecute); + super(name, description, options ?? [], onCommandExecute); this.dependencies = dependencies; // the preset for this is a "safe" default, // so we just don't set it at all @@ -145,14 +223,14 @@ export class RootModule extends BaseModule { this.onCommandExecute(onCommandExecute); } // make sure the config exists - if (this.command in botConfig.modules) { - this.config = botConfig.modules[this.command]; + if (this.name in botConfig.modules) { + this.config = botConfig.modules[this.name]; this.enabled = this.config.enabled; } else { logEvent( EventCategory.Warning, 'core', - `No config option found for "${this.command}" in the config,` + + `No config option found for "${this.name}" in the config,` + 'this module will be disabled.', 1 ); @@ -171,7 +249,7 @@ export class RootModule extends BaseModule { * @param submoduleToRegister Submodule you'd like to add to the current Module */ registerSubModule(submoduleToRegister: SubModule): void { - submoduleToRegister.rootModuleName = this.command; + submoduleToRegister.rootModuleName = this.name; // sort of a non-null assertion, but null checks happen for the root module, // and since all subcommands are disabled, we don't need to worry about initialization. submoduleToRegister.config = this.config; @@ -197,12 +275,10 @@ export class SubModule extends BaseModule { constructor( command: string, helpMessage: string, - onCommandExecute?: ( - args: string | undefined, - msg: Message - ) => Promise + options?: ModuleInputOption[], + onCommandExecute?: ModuleCommandFunction ) { - super(command, helpMessage, onCommandExecute); + super(command, helpMessage, options ?? [], onCommandExecute); } /** @@ -232,11 +308,11 @@ export class Dependency { /** * The actual "thing" this whole class is talking about. If the dependency is an API key, * then this might be a string containing that API key. If it's a database connection, it may - * be the client provided by a wrapper library over the API + * be a client provided by a wrapper library over the API * (for example, the [MongoDB client](https://mongodb.github.io/node-mongodb-native/api-generated/mongoclient.html)). * * This is marked private because we don't want people to directly try to read from the value, because - * then it won't go through all of the robust checks and such. To access this value, + * It's preferred that they use "smarter" methods to access this value, * use exposed methods like {@link resolve()} * * The resting state of this value is `null`. This means that no attempt has been made to resolve diff --git a/src/modules/channel_logging.ts b/src/modules/channel_logging.ts index 76febd0..66e99ac 100644 --- a/src/modules/channel_logging.ts +++ b/src/modules/channel_logging.ts @@ -172,6 +172,7 @@ class MessageRingBuffer { const channelLogging = new util.RootModule( 'logging', 'Manage discord channel and thread logging', + [], [] ); @@ -242,9 +243,17 @@ channelLogging.onInitialize(async () => { const populate = new util.SubModule( 'populate', - 'Fill the channel map in the config, and automatically start logging. ' + - "\nThis requires `loggingCategory` to be set. Takes a list of channel IDs that you don't want", - async (args, msg) => { + 'Generate needed logging channels and populate the config', + [ + { + type: util.ModuleOptionType.String, + name: 'blacklist', + description: + 'A list of channels, separated by spaces that you want to add to the blacklist.', + required: false, + }, + ], + async (args, interaction) => { const loggingCategory = util.guild.channels.cache.get( populate.config.loggingCategory ) as CategoryChannel; @@ -281,9 +290,11 @@ const populate = new util.SubModule( // blacklisted channels are disabled by default let channelBlacklist: string[] = populate.config.channelBlacklist; // blacklisted channels should be passed as channel ids, separated by a space - if (args) { + const inputBlacklist = args.filter(option => option.name === 'blacklist')[0] + .value; + if (inputBlacklist !== undefined) { // possibly undefined: verified that args were passed first - for (const channel of args?.split(' ')) { + for (const channel of inputBlacklist as string) { channelBlacklist.push(channel); } } @@ -325,7 +336,7 @@ const populate = new util.SubModule( confirmButton ); /** this message contains the selection menu */ - const botResponse = await msg.reply({ + const botResponse = await interaction.reply({ embeds: [ util.embed.infoEmbed('Select channels to exclude from logging:'), ], @@ -335,10 +346,10 @@ const populate = new util.SubModule( /** * This rather inelegant and badly function does basically everything, and is only in a function because it needs to be * done for a button interaction or a text select menu, with only minor deviations in between - * @param interaction Pass this from the interaction listener (https://discordjs.guide/message-components/interactions.html#component-collectors) + * @param menuInteraction Pass this from the interaction listener (https://discordjs.guide/message-components/interactions.html#component-collectors) */ async function generateChannelsAndPopulateConfig( - interaction: StringSelectMenuInteraction | ButtonInteraction + menuInteraction: StringSelectMenuInteraction | ButtonInteraction ): Promise { // blacklisted channels are dropped from the channels collection entirely, // and we pretend they don't exist from a logging perspective @@ -376,12 +387,12 @@ const populate = new util.SubModule( .confirmEmbed( 'New logging channels for these channels will be made:\n' + unloggedChannels.join('\n'), - msg + interaction ) .then(async choice => { switch (choice) { case util.ConfirmEmbedResponse.Confirmed: - interaction.followUp({ + menuInteraction.followUp({ embeds: [util.embed.infoEmbed('Generating new channels...')], components: [], }); @@ -408,7 +419,7 @@ const populate = new util.SubModule( break; case util.ConfirmEmbedResponse.Denied: - interaction.followUp({ + menuInteraction.followUp({ embeds: [ util.embed.infoEmbed( 'New channels will not be generated, moving on to config population.' @@ -420,7 +431,7 @@ const populate = new util.SubModule( } }); } else { - await interaction.followUp({ + await menuInteraction.followUp({ embeds: [ util.embed.infoEmbed( 'No new channels need generation, moving onto config updates...' @@ -429,7 +440,7 @@ const populate = new util.SubModule( }); } - await interaction.followUp({ + await menuInteraction.followUp({ embeds: [ util.embed.infoEmbed( 'Generating and applying the correct config options...' @@ -461,7 +472,7 @@ const populate = new util.SubModule( ['modules', 'logging', 'channelMap'], channelMap ); - interaction.followUp({ + menuInteraction.followUp({ embeds: [ util.embed.successEmbed('Config updated and logging deployed.'), ], @@ -470,7 +481,7 @@ const populate = new util.SubModule( const continueButtonListener = botResponse.createMessageComponentCollector({ componentType: ComponentType.Button, - filter: i => msg.author.id === i.user.id, + filter: i => interaction.user.id === i.user.id, time: 60_000, }); @@ -494,7 +505,7 @@ const populate = new util.SubModule( // time is in MS const channelSelectListener = botResponse.createMessageComponentCollector({ componentType: ComponentType.StringSelect, - filter: i => msg.author.id === i.user.id, + filter: i => interaction.user.id === i.user.id, time: 60_000, }); diff --git a/src/modules/factoids/factoids.ts b/src/modules/factoids/factoids.ts index b1fafe1..9c3c496 100644 --- a/src/modules/factoids/factoids.ts +++ b/src/modules/factoids/factoids.ts @@ -27,6 +27,7 @@ const factoid = new util.RootModule( 'Manage or fetch user generated messages', [util.mongo] ); +// TODO: implement an LRU factoid cache factoid.onInitialize(async () => { // these are defined outside so that they don't get redefined every time a @@ -76,8 +77,17 @@ factoid.registerSubModule( new util.SubModule( 'get', 'Fetch a factoid from the database and return it', + [ + { + type: util.ModuleOptionType.String, + name: 'factoid', + description: 'The factoid to fetch', + required: true, + }, + ], async (args, msg) => { - const factoidName: string | undefined = args?.split(' ')[0]; + const factoidName: string = + (args.filter(arg => arg.name === 'factoid')[0].value as string) ?? ''; if (factoidName === '') { return util.embed.errorEmbed( 'No factoid name provided, please specify a factoid.' @@ -114,12 +124,28 @@ factoid.registerSubModule( new util.SubModule( 'remember', 'Register a new factoid', - async (args, msg) => { + [ + { + type: util.ModuleOptionType.String, + name: 'name', + description: 'The name of the factoid', + required: true, + }, + { + type: util.ModuleOptionType.Attachment, + name: 'factoid', + description: 'A .json describing a valid factoid', + required: true, + }, + ], + async (args, interaction) => { const db: Db = util.mongo.fetchValue(); const factoids = db.collection(FACTOID_COLLECTION_NAME); // first see if they uploaded a factoid // the json upload - const uploadedFactoid: Attachment | undefined = msg.attachments.first(); + const uploadedFactoid: Attachment | undefined = args.filter( + arg => arg.name === 'factoid' + )[0].attachment; if (uploadedFactoid === undefined) { return util.embed.errorEmbed('No attachments provided'); } @@ -156,7 +182,11 @@ factoid.registerSubModule( } // the structure sent to the database const factoid: Factoid = { - name: args.split(' ')[0], + // the option is *required* so this option should always exist, + // but you're not supposed use non-null assertion after filter calls + name: + args.filter(arg => arg.name === 'name')[0].value?.toString() ?? + 'somethingBrokeThisShouldBeImpossible', aliases: [], hidden: false, message: JSON.parse(serializedFactoid), @@ -183,30 +213,40 @@ factoid.registerSubModule( ); factoid.registerSubModule( - new util.SubModule('forget', 'Remove a factoid', async args => { - const factoidName = args?.split(' ')[0]; - if (factoidName === '') { - return util.embed.errorEmbed( - 'No factoid name provided, please specify a factoid' - ); - } - const db: Db = util.mongo.fetchValue(); - const factoids: Collection = db.collection( - FACTOID_COLLECTION_NAME - ); - const result: DeleteResult = await factoids.deleteOne({name: factoidName}); - - if (result.deletedCount === 0) { - return util.embed.errorEmbed( - `Deletion failed, unable to find factoid \`${factoidName}\`` - ); - } else { - // if stuff was deleted, than we probably found the factoid, return success - return util.embed.successEmbed( - `Factoid successfully deleted: \`${factoidName}\`` + new util.SubModule( + 'forget', + 'Remove a factoid', + [ + { + type: util.ModuleOptionType.String, + name: 'factoid', + description: 'The factoid to forget', + required: true, + }, + ], + async args => { + const factoidName = args.find(arg => arg.name === 'factoid')! + .value as string; + const db: Db = util.mongo.fetchValue(); + const factoids: Collection = db.collection( + FACTOID_COLLECTION_NAME ); + const result: DeleteResult = await factoids.deleteOne({ + name: factoidName, + }); + + if (result.deletedCount === 0) { + return util.embed.errorEmbed( + `Deletion failed, unable to find factoid \`${factoidName}\`` + ); + } else { + // if stuff was deleted, than we probably found the factoid, return success + return util.embed.successEmbed( + `Factoid successfully deleted: \`${factoidName}\`` + ); + } } - }) + ) ); factoid.registerSubModule( @@ -216,4 +256,12 @@ factoid.registerSubModule( new util.SubModule('all', 'Generate a list of all factoids as a webpage') ); +// NOTE: THE BELOW IS TEMPORARY AS A TEST +const ping = new util.SubModule('ping', 'ping the ping'); +const pong = new util.SubModule('pong', 'pong the pong', [], async () => { + console.log('hee heee'); +}); +factoid.registerSubModule(ping); +ping.registerSubmodule(pong); + export default factoid; diff --git a/tests/logger.test.ts b/tests/logger.test.ts deleted file mode 100644 index ff889f4..0000000 --- a/tests/logger.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import {describe, it} from 'node:test'; -import chalk from 'chalk'; - -import {EventCategory, logEvent} from '../src/core/logger'; -import {botConfig} from '../src/core/config'; - -// This should probably be removed later, I'd like to make tests run when -// the bot is connected to discord and started so that tests don't need -// to emulate a whole api -botConfig.readConfigFromFileSystem(); - -describe('testing logging', () => { - it('should support color', () => { - if (!chalk.supportsColor) throw 'This terminal does not support color'; - }); - - it('should log an information event', () => { - logEvent(EventCategory.Info, 'testing', 'logging an information event', 3); - }); - - it('should log a warning event', () => { - logEvent(EventCategory.Warning, 'testing', 'logging a warning event', 3); - }); - - it('should log an error event', () => { - logEvent(EventCategory.Error, 'testing', 'logging an error event', 3); - }); -});