diff --git a/README.md b/README.md new file mode 100644 index 0000000..b82e02d --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# The PnP Scheduler Discord-Bot + +Invite: https://discord.com/oauth2/authorize?client_id=1347546897170173985 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 3db9cbf..f830d8c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,9 +18,11 @@ "discord.js": "^14.18.0", "dotenv": "^16.4.7", "esbuild": "^0.25.0", + "ics": "^3.8.1", "is-plain-object": "^5.0.0", "log4js": "^6.9.1", "node-cron": "^4.0.7", + "node-ical": "^0.20.1", "object-path-set": "^1.0.2", "svg2img": "^1.0.0-beta.2" }, @@ -2048,6 +2050,12 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/atob": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", @@ -2060,6 +2068,17 @@ "node": ">= 4.5.0" } }, + "node_modules/axios": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", + "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2193,6 +2212,19 @@ "node": ">=0.4.0" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2255,6 +2287,18 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2343,6 +2387,15 @@ "node": ">=0.10.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/detect-libc": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", @@ -2405,6 +2458,20 @@ "url": "https://dotenvx.com" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -2414,6 +2481,51 @@ "once": "^1.4.0" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.25.0", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", @@ -2820,6 +2932,22 @@ } } }, + "node_modules/form-data": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", + "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -2840,6 +2968,52 @@ "node": ">=6 <7 || >=8" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/gifwrap": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/gifwrap/-/gifwrap-0.9.4.tgz", @@ -2892,6 +3066,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -2915,6 +3101,56 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ics": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/ics/-/ics-3.8.1.tgz", + "integrity": "sha512-UqQlfkajfhrS4pUGQfGIJMYz/Jsl/ob3LqcfEhUmLbwumg+ZNkU0/6S734Vsjq3/FYNpEcZVKodLBoe+zBM69g==", + "license": "ISC", + "dependencies": { + "nanoid": "^3.1.23", + "runes2": "^1.1.2", + "yup": "^1.2.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -3225,6 +3461,15 @@ "integrity": "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA==", "license": "MIT" }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3261,6 +3506,27 @@ "node": ">=4" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", @@ -3321,12 +3587,51 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/moment-timezone": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz", + "integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==", + "license": "MIT", + "dependencies": { + "moment": "^2.29.4" + }, + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/napi-build-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", @@ -3361,6 +3666,18 @@ "node": ">=6.0.0" } }, + "node_modules/node-ical": { + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/node-ical/-/node-ical-0.20.1.tgz", + "integrity": "sha512-NrXgzDJd6XcyX9kDMJVA3xYCZmntY7ghA2BOdBeYr3iu8tydHOAb+68jPQhF9V2CRQ0/386X05XhmLzQUN0+Hw==", + "license": "Apache-2.0", + "dependencies": { + "axios": "^1.7.7", + "moment-timezone": "^0.5.45", + "rrule": "2.8.1", + "uuid": "^10.0.0" + } + }, "node_modules/object-path-set": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/object-path-set/-/object-path-set-1.0.2.tgz", @@ -3598,6 +3915,18 @@ "node": ">= 0.6.0" } }, + "node_modules/property-expr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", + "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==", + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/pump": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", @@ -3757,6 +4086,15 @@ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "license": "MIT" }, + "node_modules/rrule": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/rrule/-/rrule-2.8.1.tgz", + "integrity": "sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -3781,6 +4119,12 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/runes2": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/runes2/-/runes2-1.1.4.tgz", + "integrity": "sha512-LNPnEDPOOU4ehF71m5JoQyzT2yxwD6ZreFJ7MxZUAoMKNMY1XrAo60H1CUoX5ncSm0rIuKlqn9JZNRrRkNou2g==", + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -3998,6 +4342,12 @@ "integrity": "sha512-IjZc9KIotudix8bMaBW6QvMuq64BrJWFs1+4V0lXwWGQZwH+LnX87doAYhem4caOEusRP9/g6jVDQmZ8XOk1nw==", "license": "MIT" }, + "node_modules/tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==", + "license": "MIT" + }, "node_modules/tinycolor2": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", @@ -4034,6 +4384,12 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -4084,6 +4440,18 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", @@ -4171,6 +4539,19 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4285,6 +4666,18 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/yup": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.6.1.tgz", + "integrity": "sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==", + "license": "MIT", + "dependencies": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + } } } } diff --git a/package.json b/package.json index 1f61d35..d3b34fc 100644 --- a/package.json +++ b/package.json @@ -23,9 +23,11 @@ "discord.js": "^14.18.0", "dotenv": "^16.4.7", "esbuild": "^0.25.0", + "ics": "^3.8.1", "is-plain-object": "^5.0.0", "log4js": "^6.9.1", "node-cron": "^4.0.7", + "node-ical": "^0.20.1", "object-path-set": "^1.0.2", "svg2img": "^1.0.0-beta.2" }, diff --git a/source/Container/Container.ts b/source/Container/Container.ts index 8909c24..a92750e 100644 --- a/source/Container/Container.ts +++ b/source/Container/Container.ts @@ -1,29 +1,28 @@ import {Class} from "../types/Class"; export class Container { - static instance: Container; - - private instances: Map = new Map(); - - public set(instance: T, name: string|null = null): void - { - const settingName = name ?? instance.constructor.name; - this.instances.set(settingName.toLowerCase(), instance); - } - - public get(name: string): T - { - return this.instances.get(name.toLowerCase()); - } - - static getInstance(): Container { - if (!Container.instance) { - Container.instance = new Container(); - } - - return Container.instance; - } - public static get(name: string): T { - return Container.instance.get(name); + static instance: Container; + + private instances: Map = new Map(); + + public set(instance: T, name: string | null = null): void { + const settingName = name ?? instance.constructor.name; + this.instances.set(settingName.toLowerCase(), instance); + } + + public get(name: string): T { + return this.instances.get(name.toLowerCase()); + } + + static getInstance(): Container { + if (!Container.instance) { + Container.instance = new Container(); } + + return Container.instance; + } + + public static get(name: string): T { + return Container.instance.get(name); + } } \ No newline at end of file diff --git a/source/Container/Services.ts b/source/Container/Services.ts index 7c5b4ad..82dfbf9 100644 --- a/source/Container/Services.ts +++ b/source/Container/Services.ts @@ -11,69 +11,77 @@ import {IconCache} from "../Icons/IconCache"; import {EventHandler} from "../Events/EventHandler"; import {InteractionRouter} from "../Discord/InteractionRouter"; import Commands from "../Discord/Commands/Commands"; +import {CommandDeployer} from "../Discord/CommandDeployer"; +import {REST} from "discord.js"; +import {log} from "node:util"; export enum ServiceHint { - App, - Deploy + App, + Deploy } export class Services { - public static setup(container: Container, hint: ServiceHint) { - const env = new Environment(); - env.setup(); - container.set(env); - - const logger = this.setupLogger(hint); - container.set(logger, 'logger'); + public static setup(container: Container, hint: ServiceHint) { + const env = new Environment(); + env.setup(); + container.set(env); + + const logger = this.setupLogger(hint); + container.set(logger, 'logger'); + + const database = new DatabaseConnection(env.database); + container.set(database); + + const restClient = new REST(); + const commands = new Commands(); + const discordClient = new DiscordClient( + env.discord.clientId, + new InteractionRouter(commands, logger), + new CommandDeployer(env.discord.clientId, restClient, commands, logger), + logger, + restClient + ); + container.set(discordClient); + + const iconCache = new IconCache(discordClient); + container.set(iconCache); + + container.set(new EventHandler()); + this.setupRepositories(container); + } + + private static setupRepositories(container: Container) { + const db = container.get(DatabaseConnection.name); + container.set(new GroupRepository(db)); + container.set(new PlaydateRepository(db, container.get(GroupRepository.name))) + container.set(new GroupConfigurationRepository(db, container.get(GroupRepository.name))) + } + + private static setupLogger(hint: ServiceHint): Logger { + + configure({ + appenders: { + out: {type: "stdout"}, + appLogFile: {type: "file", filename: path.resolve("logs/run.log")}, + deployLogFile: {type: "file", filename: path.resolve("logs/deploy.log")}, + }, + categories: { + default: {appenders: ['out'], level: 'debug'}, + app: {appenders: ["out", "appLogFile"], level: "debug"}, + deploy: {appenders: ["out", "deployLogFile"], level: "debug"}, + } + }) + + let loggername = ''; + switch (hint) { + case ServiceHint.App: + loggername = "app"; + break; + case ServiceHint.Deploy: + loggername = "deploy"; + break; - const database = new DatabaseConnection(env.database); - container.set(database); - - const discordClient = new DiscordClient( - env.discord.clientId, - new InteractionRouter(new Commands(), logger) - ); - container.set(discordClient); - - const iconCache = new IconCache(discordClient); - container.set(iconCache); - - container.set(new EventHandler()); - this.setupRepositories(container); - } - - private static setupRepositories(container: Container) { - const db = container.get(DatabaseConnection.name); - container.set(new GroupRepository(db)); - container.set(new PlaydateRepository(db, container.get(GroupRepository.name))) - container.set(new GroupConfigurationRepository(db, container.get(GroupRepository.name))) - } - - private static setupLogger(hint: ServiceHint): Logger { - - configure({ - appenders: { - out: { type: "stdout" }, - appLogFile: { type: "file", filename: path.resolve("logs/run.log")}, - deployLogFile: { type: "file", filename: path.resolve("logs/deploy.log")}, - }, - categories: { - default: { appenders: ['out'], level: 'debug' }, - app: { appenders: ["out", "appLogFile"], level: "debug" }, - deploy: { appenders: ["out", "deployLogFile"], level: "debug" }, - } - }) - - let loggername = ''; - switch (hint) { - case ServiceHint.App: - loggername = "app"; - break; - case ServiceHint.Deploy: - loggername = "deploy"; - break; - - } - return getLogger(loggername); } + return getLogger(loggername); + } } \ No newline at end of file diff --git a/source/Database/DatabaseConnection.ts b/source/Database/DatabaseConnection.ts index d411a7d..cfef193 100644 --- a/source/Database/DatabaseConnection.ts +++ b/source/Database/DatabaseConnection.ts @@ -5,38 +5,39 @@ import {Container} from "../Container/Container"; import {Logger} from "log4js"; export class DatabaseConnection { - private database: Sqlite3.Database; - - constructor(env: DatabaseEnvironment) { - this.database = new Database(env.path, { - nativeBinding: "node_modules/better-sqlite3/build/Release/better_sqlite3.node", - }) - this.database.pragma('journal_mode = WAL'); - } - - public execute(query: string, ...args: unknown[]): Sqlite3.RunResult { - try { - const preparedQuery = this.database.prepare(query); - return preparedQuery.run(args); - } catch (error) { - Container.get("logger").error("Failed to execute database connection", error, query, args); - throw error; - } - } + private database: Sqlite3.Database; - public fetch(query: string, ...args: unknown[]): Result|undefined { - const preparedQuery = this.database.prepare(query); - return preparedQuery.get(args); - } - public fetchAll(query: string, ...args: unknown[]): Result[] { - const preparedQuery = this.database.prepare(query); - return preparedQuery.all(args); - } - - public hasTable(tableName: string): boolean { - const sql = "SELECT COUNT(*) as tableCount FROM sqlite_master WHERE type='table' AND name=? LIMIT 1;"; - - const result = this.fetch(sql, tableName); - return result != undefined && result.tableCount > 0; + constructor(env: DatabaseEnvironment) { + this.database = new Database(env.path, { + nativeBinding: "node_modules/better-sqlite3/build/Release/better_sqlite3.node", + }) + this.database.pragma('journal_mode = WAL'); + } + + public execute(query: string, ...args: unknown[]): Sqlite3.RunResult { + try { + const preparedQuery = this.database.prepare(query); + return preparedQuery.run(args); + } catch (error) { + Container.get("logger").error("Failed to execute database connection", error, query, args); + throw error; } + } + + public fetch(query: string, ...args: unknown[]): Result | undefined { + const preparedQuery = this.database.prepare(query); + return preparedQuery.get(args); + } + + public fetchAll(query: string, ...args: unknown[]): Result[] { + const preparedQuery = this.database.prepare(query); + return preparedQuery.all(args); + } + + public hasTable(tableName: string): boolean { + const sql = "SELECT COUNT(*) as tableCount FROM sqlite_master WHERE type='table' AND name=? LIMIT 1;"; + + const result = this.fetch(sql, tableName); + return result != undefined && result.tableCount > 0; + } } \ No newline at end of file diff --git a/source/Database/DatabaseDefinition.ts b/source/Database/DatabaseDefinition.ts index f83419d..b650205 100644 --- a/source/Database/DatabaseDefinition.ts +++ b/source/Database/DatabaseDefinition.ts @@ -1,13 +1,13 @@ export type DatabaseColumnDefinition = { - name: string; - type: string; - primaryKey?: boolean; - autoIncrement?: boolean; - notNull?: boolean; - options?: string; + name: string; + type: string; + primaryKey?: boolean; + autoIncrement?: boolean; + notNull?: boolean; + options?: string; } export type DatabaseDefinition = { - name: string; - columns: DatabaseColumnDefinition[]; + name: string; + columns: DatabaseColumnDefinition[]; } \ No newline at end of file diff --git a/source/Database/DatabaseUpdater.ts b/source/Database/DatabaseUpdater.ts index a5581c8..f08a5c9 100644 --- a/source/Database/DatabaseUpdater.ts +++ b/source/Database/DatabaseUpdater.ts @@ -4,78 +4,82 @@ import {Container} from "../Container/Container"; import {Logger} from "log4js"; export class DatabaseUpdater { - constructor(private readonly database: DatabaseConnection) {} - - public ensureAvaliablity(definitions: Iterable) { - for (const definition of definitions) { - this.ensureDefinition(definition); - } - } - - private ensureDefinition(definition: DatabaseDefinition) { - if (this.database.hasTable(definition.name)) { - this.ensureTableColumns(definition); - return; - } - - this.createTable(definition); - } - - private ensureTableColumns(definition: DatabaseDefinition) { - const DBSQLColumns = this.database.fetchAll( - `PRAGMA table_info("${definition.name}")` - ); - - if (!DBSQLColumns) { - Container.get("logger").log("Request failed..."); - return; - } - - const missingColumns = definition.columns.filter( - (column: DatabaseColumnDefinition) => { - return !DBSQLColumns.some((dbColumn: DatabaseColumnDefinition) => { - return column.name === dbColumn.name - }); - } - ) - - if (missingColumns.length < 1) { - Container.get("logger").log(`No new columns found for ${definition.name}`) - return; - } - - const columnsSQL = missingColumns.map((column: DatabaseColumnDefinition) => { - const values = [ - "ADD", - column.name, - column.type, - column.primaryKey ? `PRIMARY KEY` : '', - column.notNull ? 'NOT NULL' : '', - column.autoIncrement ? 'AUTOINCREMENT' : '', - ] + constructor(private readonly database: DatabaseConnection) { + } - return values.join(' '); - }).join(', '); - - const sql = `ALTER TABLE ${definition.name} ${columnsSQL}`; - this.database.execute(sql); + public ensureAvaliablity(definitions: Iterable) { + for (const definition of definitions) { + this.ensureDefinition(definition); + } + } + + private ensureDefinition(definition: DatabaseDefinition) { + if (this.database.hasTable(definition.name)) { + this.ensureTableColumns(definition); + return; } + this.createTable(definition); + } - private createTable(definition: DatabaseDefinition) { - const columnsSQL = definition.columns.map((column: DatabaseColumnDefinition) => { - const values = [ - column.name, - column.type, - column.primaryKey ? `PRIMARY KEY` : '', - column.notNull ? 'NOT NULL' : '', - column.autoIncrement ? 'AUTOINCREMENT' : '', - ] - - return values.join(' '); - }).join(', '); - - const sql = `CREATE TABLE IF NOT EXISTS ${definition.name} (${columnsSQL})`; - this.database.execute(sql); + private ensureTableColumns(definition: DatabaseDefinition) { + const DBSQLColumns = this.database.fetchAll( + `PRAGMA table_info("${definition.name}")` + ); + + if (!DBSQLColumns) { + Container.get("logger").log("Request failed..."); + return; } + + const missingColumns = definition.columns.filter( + (column: DatabaseColumnDefinition) => { + return !DBSQLColumns.some((dbColumn: DatabaseColumnDefinition) => { + return column.name === dbColumn.name + }); + } + ) + + if (missingColumns.length < 1) { + Container.get("logger").log(`No new columns found for ${definition.name}`) + return; + } + + const columnsSQL = missingColumns.map((column: DatabaseColumnDefinition) => { + const values = [ + "ADD", + column.name, + column.type, + column.primaryKey ? `PRIMARY KEY` : '', + column.notNull ? 'NOT NULL' : '', + column.autoIncrement ? 'AUTOINCREMENT' : '', + ] + + return values.join(' '); + }).join(', '); + + const sql = `ALTER TABLE ${definition.name} ${columnsSQL}`; + this.database.execute(sql); + } + + + private createTable(definition: DatabaseDefinition) { + const columnsSQL = definition.columns.map((column: DatabaseColumnDefinition) => { + const values = [ + column.name, + column.type, + column.primaryKey ? `PRIMARY KEY` : '', + column.notNull ? 'NOT NULL' : '', + column.autoIncrement ? 'AUTOINCREMENT' : '', + ] + + return values.join(' '); + }).join(', '); + + const sql = `CREATE TABLE IF NOT EXISTS ${definition.name} + ( + ${columnsSQL} + )`; + this.database.execute(sql); + } } \ No newline at end of file diff --git a/source/Database/definitions.ts b/source/Database/definitions.ts index 6085381..96e7ceb 100644 --- a/source/Database/definitions.ts +++ b/source/Database/definitions.ts @@ -4,9 +4,9 @@ import Playdate from "./tables/Playdate"; import GroupConfiguration from "./tables/GroupConfiguration"; const definitions = new Set([ - Groups, - Playdate, - GroupConfiguration + Groups, + Playdate, + GroupConfiguration ]); export default definitions; \ No newline at end of file diff --git a/source/Database/tables/GroupConfiguration.ts b/source/Database/tables/GroupConfiguration.ts index 2ef476f..e5449fc 100644 --- a/source/Database/tables/GroupConfiguration.ts +++ b/source/Database/tables/GroupConfiguration.ts @@ -1,34 +1,34 @@ import {DatabaseDefinition} from "../DatabaseDefinition"; export type DBGroupConfiguration = { - id: number; - groupid: number; - key: string; - value: string; + id: number; + groupid: number; + key: string; + value: string; } const dbDefinition: DatabaseDefinition = { - name: "groupConfiguration", - columns: [ - { - name: "id", - type: "INTEGER", - autoIncrement: true, - primaryKey: true, - }, - { - name: "groupid", - type: "VARCHAR(32)", - }, - { - name: "key", - type: "VARCHAR(32)", - }, - { - name: "value", - type: "VARCHAR(128)", - } - ] + name: "groupConfiguration", + columns: [ + { + name: "id", + type: "INTEGER", + autoIncrement: true, + primaryKey: true, + }, + { + name: "groupid", + type: "VARCHAR(32)", + }, + { + name: "key", + type: "VARCHAR(32)", + }, + { + name: "value", + type: "VARCHAR(128)", + } + ] } export default dbDefinition; \ No newline at end of file diff --git a/source/Database/tables/Groups.ts b/source/Database/tables/Groups.ts index 61aae0c..3929355 100644 --- a/source/Database/tables/Groups.ts +++ b/source/Database/tables/Groups.ts @@ -1,39 +1,39 @@ import {DatabaseDefinition} from "../DatabaseDefinition"; export type DBGroup = { - id: number; - name: string; - server: string; - leader: string; - role: string; + id: number; + name: string; + server: string; + leader: string; + role: string; } const dbDefinition: DatabaseDefinition = { - name: "groups", - columns: [ - { - name: "id", - type: "INTEGER", - autoIncrement: true, - primaryKey: true, - }, - { - name: "server", - type: "VARCHAR(32)" - }, - { - name: "name", - type: "VARCHAR(32)", - }, - { - name: "leader", - type: "VARCHAR(32)", - }, - { - name: "role", - type: "VARCHAR(32)", - } - ] + name: "groups", + columns: [ + { + name: "id", + type: "INTEGER", + autoIncrement: true, + primaryKey: true, + }, + { + name: "server", + type: "VARCHAR(32)" + }, + { + name: "name", + type: "VARCHAR(32)", + }, + { + name: "leader", + type: "VARCHAR(32)", + }, + { + name: "role", + type: "VARCHAR(32)", + } + ] } export default dbDefinition; \ No newline at end of file diff --git a/source/Database/tables/Playdate.ts b/source/Database/tables/Playdate.ts index 889653c..68fdb70 100644 --- a/source/Database/tables/Playdate.ts +++ b/source/Database/tables/Playdate.ts @@ -1,35 +1,35 @@ import {DatabaseDefinition} from "../DatabaseDefinition"; export type DBPlaydate = { - id: number; - groupid: number; + id: number; + groupid: number; - time_from: number; - time_to: number; + time_from: number; + time_to: number; } const dbDefinition: DatabaseDefinition = { - name: "playdates", - columns: [ - { - name: "id", - type: "INTEGER", - autoIncrement: true, - primaryKey: true, - }, - { - name: "groupid", - type: "INTEGER", - }, - { - name: "time_from", - type: "TIMESTAMP", - }, - { - name: "time_to", - type: "TIMESTAMP", - } - ] + name: "playdates", + columns: [ + { + name: "id", + type: "INTEGER", + autoIncrement: true, + primaryKey: true, + }, + { + name: "groupid", + type: "INTEGER", + }, + { + name: "time_from", + type: "TIMESTAMP", + }, + { + name: "time_to", + type: "TIMESTAMP", + } + ] } export default dbDefinition; \ No newline at end of file diff --git a/source/Discord/CommandDeployer.ts b/source/Discord/CommandDeployer.ts index b0e3764..b1babed 100644 --- a/source/Discord/CommandDeployer.ts +++ b/source/Discord/CommandDeployer.ts @@ -1,36 +1,28 @@ import {DiscordClient} from "./DiscordClient"; import {Logger} from "log4js"; -import {Routes, Snowflake} from "discord.js"; +import {REST, Routes, Snowflake} from "discord.js"; +import Commands from "./Commands/Commands"; export class CommandDeployer { - constructor( - private readonly client: DiscordClient, - private readonly logger: Logger - ) { - } - - public async deployAvailableServers() { - const commandInfos: object[] = []; - this.client.Commands.allCommands.forEach((command) => { - commandInfos.push(command.definition().toJSON()) - }) - - const guilds = await this.client.RESTClient.get(Routes.userGuilds()); - const deployments = guilds.map(guild => { - return this.deployServer(commandInfos, guild.id) - }) - - await Promise.all(deployments); - } - - private async deployServer(commandInfos: object[], serverId: Snowflake) { - this.logger.log(`Started refreshing ${commandInfos.length} application (/) commands for ${serverId}.`); + constructor( + private readonly applicationId: string, + private readonly restClient: REST, + private readonly commands: Commands, + private readonly logger: Logger + ) { + } - // The put method is used to fully refresh all commands in the guild with the current set - await this.client.RESTClient.put( - Routes.applicationGuildCommands(this.client.ApplicationId, serverId), - { body: commandInfos }, - ); - this.logger.log(`Successfully reloaded ${commandInfos.length} application (/) commands for ${serverId}.`); - } + public async deployServer(serverId: Snowflake) { + const commandInfos = this.commands.getJsonDefinitions(); + + this.logger.debug(`Started refreshing ${commandInfos.length} application (/) commands for ${serverId}.`); + + // The put method is used to fully refresh all commands in the guild with the current set + await this.restClient.put( + Routes.applicationGuildCommands(this.applicationId, serverId), + {body: commandInfos}, + ); + + this.logger.debug(`Successfully reloaded ${commandInfos.length} application (/) commands for ${serverId}.`); + } } \ No newline at end of file diff --git a/source/Discord/CommandPartials/GroupSelection.ts b/source/Discord/CommandPartials/GroupSelection.ts index 8ecbdd8..cb1fe7c 100644 --- a/source/Discord/CommandPartials/GroupSelection.ts +++ b/source/Discord/CommandPartials/GroupSelection.ts @@ -1,7 +1,7 @@ import { - AutocompleteInteraction, - CommandInteraction, - GuildMember, SlashCommandIntegerOption, + AutocompleteInteraction, + CommandInteraction, + GuildMember, SlashCommandIntegerOption, } from "discord.js"; import {Container} from "../../Container/Container"; import {GroupRepository} from "../../Repositories/GroupRepository"; @@ -9,36 +9,36 @@ import {GroupModel} from "../../Models/GroupModel"; import {UserError} from "../UserError"; export class GroupSelection { - public static createOptionSetup(): SlashCommandIntegerOption { - return new SlashCommandIntegerOption() - .setName("group") - .setDescription("Defines the group this action is for") - .setRequired(true) - .setAutocomplete(true) + public static createOptionSetup(): SlashCommandIntegerOption { + return new SlashCommandIntegerOption() + .setName("group") + .setDescription("Defines the group this action is for") + .setRequired(true) + .setAutocomplete(true) + } + + public static async handleAutocomplete(interaction: AutocompleteInteraction, onlyLeaders: boolean = false): Promise { + const value = interaction.options.getFocused(); + const repo = Container.get(GroupRepository.name); + const groups = repo.findGroupsByMember(interaction.member, onlyLeaders); + await interaction.respond( + groups + .filter((group) => group.name.startsWith(value)) + .map((group) => ({name: group.name, value: group.id})) + ) + } + + public static getGroup(interaction: CommandInteraction | AutocompleteInteraction): GroupModel { + const groupname = interaction.options.get("group", true); + if (!groupname) { + throw new UserError("No group name provided"); } - - public static async handleAutocomplete(interaction: AutocompleteInteraction, onlyLeaders: boolean = false): Promise { - const value = interaction.options.getFocused(); - const repo = Container.get(GroupRepository.name); - const groups = repo.findGroupsByMember(interaction.member, onlyLeaders); - await interaction.respond( - groups - .filter((group) => group.name.startsWith(value)) - .map((group) => ({name: group.name, value: group.id })) - ) - } - - public static getGroup(interaction: CommandInteraction|AutocompleteInteraction): GroupModel { - const groupname = interaction.options.get("group", true); - if (!groupname) { - throw new UserError("No group name provided"); - } - - const group = Container.get(GroupRepository.name).getById((groupname.value ?? 0)); - if (!group) { - throw new UserError("No group found"); - } - - return group; + + const group = Container.get(GroupRepository.name).getById((groupname.value ?? 0)); + if (!group) { + throw new UserError("No group found"); } + + return group; + } } \ No newline at end of file diff --git a/source/Discord/Commands/Command.ts b/source/Discord/Commands/Command.ts index 0296f6c..8ed4562 100644 --- a/source/Discord/Commands/Command.ts +++ b/source/Discord/Commands/Command.ts @@ -1,16 +1,16 @@ import {ChatInputCommandInteraction, Interaction, SlashCommandBuilder} from "discord.js"; export interface Command { - definition(): SlashCommandBuilder; + definition(): SlashCommandBuilder; } export interface ChatInteractionCommand { - execute(interaction: ChatInputCommandInteraction): Promise; + execute(interaction: ChatInputCommandInteraction): Promise; } export interface AutocompleteCommand { - handleAutocomplete(interaction: Interaction): Promise; + handleAutocomplete(interaction: Interaction): Promise; } -export type CommandUnion = - Command | Partial | Partial; \ No newline at end of file +export type CommandUnion = + Command | Partial | Partial; \ No newline at end of file diff --git a/source/Discord/Commands/Commands.ts b/source/Discord/Commands/Commands.ts index 2b1c193..6168e4f 100644 --- a/source/Discord/Commands/Commands.ts +++ b/source/Discord/Commands/Commands.ts @@ -2,40 +2,54 @@ import {HelloWorldCommand} from "./HelloWorldCommand"; import {Command, CommandUnion} from "./Command"; import {GroupCommand} from "./Groups"; import {PlaydatesCommand} from "./Playdates"; +import {RESTPostAPIChatInputApplicationCommandsJSONBody} from "discord.js"; +import {Nullable} from "../../types/Nullable"; const commands: Set = new Set([ - new HelloWorldCommand(), - new GroupCommand(), - new PlaydatesCommand() + new HelloWorldCommand(), + new GroupCommand(), + new PlaydatesCommand() ]); export default class Commands { - private mappedCommands: Map = new Map(); - - constructor() { - this.mappedCommands = this.getMap(); + private mappedCommands: Map = new Map(); + private definitions: Nullable; + + constructor() { + this.mappedCommands = this.getMap(); + } + + public getCommand(commandName: string): CommandUnion | undefined { + if (!this.mappedCommands.has(commandName)) { + throw new Error(`Unknown command: ${commandName}`); } - - public getCommand(commandName: string): CommandUnion|undefined { - if (!this.mappedCommands.has(commandName)) { - throw new Error(`Unknown command: ${commandName}`); - } - - return this.mappedCommands.get(commandName); + + return this.mappedCommands.get(commandName); + } + + public get allCommands(): Set { + return commands; + } + + public getJsonDefinitions(): RESTPostAPIChatInputApplicationCommandsJSONBody[] { + if (this.definitions) { + return this.definitions; } - - public get allCommands(): Set { - return commands; - } - - private getMap(): Map - { - const map = new Map(); - for (const command of commands) { - const definition = command.definition() - map.set(definition.name, command); - } - - return map; + + this.definitions = []; + commands.forEach((command) => { + this.definitions?.push(command.definition().toJSON()) + }) + return this.definitions; + } + + private getMap(): Map { + const map = new Map(); + for (const command of commands) { + const definition = command.definition() + map.set(definition.name, command); } + + return map; + } } \ No newline at end of file diff --git a/source/Discord/Commands/Groups.ts b/source/Discord/Commands/Groups.ts index e025da5..ac3ea76 100644 --- a/source/Discord/Commands/Groups.ts +++ b/source/Discord/Commands/Groups.ts @@ -1,12 +1,12 @@ import { SlashCommandBuilder, ChatInputCommandInteraction, - MessageFlags, - InteractionReplyOptions, - GuildMember, - EmbedBuilder, - AutocompleteInteraction, - roleMention, time, userMention, GuildMemberRoleManager + MessageFlags, + InteractionReplyOptions, + GuildMember, + EmbedBuilder, + AutocompleteInteraction, + roleMention, time, userMention, GuildMemberRoleManager } from "discord.js"; import {AutocompleteCommand, ChatInteractionCommand, Command} from "./Command"; import {GroupModel} from "../../Models/GroupModel"; @@ -142,10 +142,10 @@ export class GroupCommand implements Command, ChatInteractionCommand, Autocomple Container.get(GroupRepository.name).create(group); - interaction.reply({content: `:white_check_mark: Created group \`${name}\``, flags: MessageFlags.Ephemeral }) + interaction.reply({content: `:white_check_mark: Created group \`${name}\``, flags: MessageFlags.Ephemeral}) } - private validateGroupName(name: string): true|string{ + private validateGroupName(name: string): true | string { const lowercaseName = name.toLowerCase(); for (const invalidcharactersequence of GroupCommand.INVALID_CHARACTER_SEQUENCES) { if (!lowercaseName.includes(invalidcharactersequence)) { @@ -162,7 +162,7 @@ export class GroupCommand implements Command, ChatInteractionCommand, Autocomple const groups = repo.findGroupsByMember(interaction.member); const playdateRepo = Container.get(PlaydateRepository.name); - + const embed = new EmbedBuilder() .setTitle("Your groups on this server:") .setFields( @@ -191,7 +191,7 @@ export class GroupCommand implements Command, ChatInteractionCommand, Autocomple embeds: [ embed ], - allowedMentions: { roles: [] }, + allowedMentions: {roles: []}, flags: MessageFlags.Ephemeral } diff --git a/source/Discord/Commands/HelloWorldCommand.ts b/source/Discord/Commands/HelloWorldCommand.ts index c023c50..ceb7a89 100644 --- a/source/Discord/Commands/HelloWorldCommand.ts +++ b/source/Discord/Commands/HelloWorldCommand.ts @@ -2,24 +2,24 @@ import {SlashCommandBuilder, CommandInteraction} from "discord.js"; import {Command} from "./Command"; export class HelloWorldCommand implements Command { - private static RESPONSES: string[] = [ - 'Hello :)', - 'zzzZ... ZzzzZ... huh? I am awake. I am awake!', - 'Roll for initiative!', - 'I was an adventurer like you...', - 'Hello :) How are you?', - ] - - definition(): SlashCommandBuilder - { - return new SlashCommandBuilder() - .setName("hello") - .setDescription("Displays a random response. (commonly used to test if the bot is online)") - } - async execute(interaction: CommandInteraction): Promise { - const random = Math.floor(Math.random() * HelloWorldCommand.RESPONSES.length); - - await interaction.reply(HelloWorldCommand.RESPONSES[random]); - return Promise.resolve(); - } + private static RESPONSES: string[] = [ + 'Hello :)', + 'zzzZ... ZzzzZ... huh? I am awake. I am awake!', + 'Roll for initiative!', + 'I was an adventurer like you...', + 'Hello :) How are you?', + ] + + definition(): SlashCommandBuilder { + return new SlashCommandBuilder() + .setName("hello") + .setDescription("Displays a random response. (commonly used to test if the bot is online)") + } + + async execute(interaction: CommandInteraction): Promise { + const random = Math.floor(Math.random() * HelloWorldCommand.RESPONSES.length); + + await interaction.reply(HelloWorldCommand.RESPONSES[random]); + return Promise.resolve(); + } } \ No newline at end of file diff --git a/source/Discord/Commands/Playdates.ts b/source/Discord/Commands/Playdates.ts index 51b1faa..ce855d8 100644 --- a/source/Discord/Commands/Playdates.ts +++ b/source/Discord/Commands/Playdates.ts @@ -1,8 +1,8 @@ import { - SlashCommandBuilder, - CommandInteraction, - AutocompleteInteraction, - EmbedBuilder, MessageFlags, ChatInputCommandInteraction, time + SlashCommandBuilder, + CommandInteraction, + AutocompleteInteraction, + EmbedBuilder, MessageFlags, ChatInputCommandInteraction, time, AttachmentBuilder, ActivityFlagsBitField, Options } from "discord.js"; import {AutocompleteCommand, ChatInteractionCommand, Command} from "./Command"; import {Container} from "../../Container/Container"; @@ -11,195 +11,325 @@ import {UserError} from "../UserError"; import {PlaydateModel} from "../../Models/PlaydateModel"; import {PlaydateRepository} from "../../Repositories/PlaydateRepository"; import {GroupModel} from "../../Models/GroupModel"; +import * as ics from 'ics'; +import ical from 'node-ical'; export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInteractionCommand { - static REGEX = [ - - ] - - definition(): SlashCommandBuilder { - // @ts-expect-error Command builder is improperly marked as incomplete. - return new SlashCommandBuilder() - .setName("playdates") - .setDescription("Manage your playdates") - .addSubcommand((subcommand) => subcommand - .setName("create") - .setDescription("Creates a new playdate") - .addIntegerOption(GroupSelection.createOptionSetup()) - .addStringOption((option) => option - .setName("from") - .setDescription("Defines the start date & time. Format: YYYY-MM-DD HH:mm") - ) - .addStringOption((option) => option - .setName("to") - .setDescription("Defines the end date & time. Format: YYYY-MM-DD HH:mm") - ) - ) - .addSubcommand((subcommand) => subcommand - .setName("list") - .setDescription("Lists all playdates") - .addIntegerOption(GroupSelection.createOptionSetup()) - ) - .addSubcommand((subcommand) => subcommand - .setName("remove") - .setDescription("Removes a playdate") - .addIntegerOption(GroupSelection.createOptionSetup()) - .addIntegerOption((option) => option - .setName("playdate") - .setDescription("Selects a playdate") - .setRequired(true) - .setAutocomplete(true) - ) - ); - } - - async execute(interaction: ChatInputCommandInteraction): Promise { - const group = GroupSelection.getGroup(interaction); - - switch (interaction.options.getSubcommand()) { - case "create": - await this.create(interaction, group); - break; - case "remove": - await this.delete(interaction, group); - break; - case "list": - await this.list(interaction, group); - break; - default: - throw new UserError("This subcommand is not yet implemented."); - } - } - - async create(interaction: CommandInteraction, group: GroupModel): Promise { - const fromDate = Date.parse(interaction.options.get("from")?.value ?? ''); - const toDate = Date.parse(interaction.options.get("to")?.value ?? ''); - - if (isNaN(fromDate)) { - throw new UserError("No date or invalid date format for the from parameter."); - } - - if (isNaN(toDate)) { - throw new UserError("No date or invalid date format for the to parameter."); - } - - if (fromDate > toDate) { - throw new UserError("The to-date can't be earlier than the from-date"); - } - - const playdateRepo = Container.get(PlaydateRepository.name); - - const collidingTimes = playdateRepo.findPlaydatesInRange(fromDate, toDate, group); - if (collidingTimes.length > 0) { - throw new UserError("The playdate collides with another playdate. Please either remove the old one or choose a different time.") - } - - const playdate: Partial = { - group: group, - from_time: new Date(fromDate), - to_time: new Date(toDate), - } - - playdateRepo.create(playdate); - - const embed = new EmbedBuilder() - .setTitle("Created a play-date.") - .setDescription(":white_check_mark: Your playdate has been created! You and your group get notified, when its time.") - .setFields({ - name: "Created playdate", - value: `${time(new Date(fromDate),'F')} - ${time(new Date(toDate), 'F')}`, - }) - .setFooter({ - text: `Group: ${group.name}` - }) - - await interaction.reply({ - embeds: [ - embed - ], - flags: MessageFlags.Ephemeral, - }) - } - - async handleAutocomplete(interaction: AutocompleteInteraction): Promise { - const option = interaction.options.getFocused(true); - if (option.name == "group") { - await GroupSelection.handleAutocomplete(interaction); - return; - } - - if (option.name != 'playdate') { - return; - } - - const group = GroupSelection.getGroup(interaction); - - const playdates = Container.get(PlaydateRepository.name).findFromGroup(group); - await interaction.respond( - playdates.map(playdate => { - return { - name: `${playdate.from_time.toLocaleString()} - ${playdate.to_time.toLocaleString()}`, - value: playdate.id - } - }) + definition(): SlashCommandBuilder { + // @ts-expect-error Command builder is improperly marked as incomplete. + return new SlashCommandBuilder() + .setName("playdates") + .setDescription("Manage your playdates") + .addSubcommand((subcommand) => subcommand + .setName("create") + .setDescription("Creates a new playdate") + .addIntegerOption(GroupSelection.createOptionSetup()) + .addStringOption((option) => option + .setName("from") + .setDescription("Defines the start date & time. Format: YYYY-MM-DD HH:mm") ) + .addStringOption((option) => option + .setName("to") + .setDescription("Defines the end date & time. Format: YYYY-MM-DD HH:mm") + ) + ) + .addSubcommand((subcommand) => subcommand + .setName("list") + .setDescription("Lists all playdates") + .addIntegerOption(GroupSelection.createOptionSetup()) + ) + .addSubcommand((subcommand) => subcommand + .setName("remove") + .setDescription("Removes a playdate") + .addIntegerOption(GroupSelection.createOptionSetup()) + .addIntegerOption((option) => option + .setName("playdate") + .setDescription("Selects a playdate") + .setRequired(true) + .setAutocomplete(true) + ) + ) + .addSubcommand(subcommand => subcommand + .setName('import') + .setDescription("Imports playdates from iCal files") + .addIntegerOption(GroupSelection.createOptionSetup()) + .addAttachmentOption(attachment => attachment + .setName("file") + .setDescription("The iCal File to import") + .setRequired(true) + ) + ) + .addSubcommand((subcommand) => subcommand + .setName("export") + .setDescription("Exports a playdate as iCal file.") + .addIntegerOption(GroupSelection.createOptionSetup()) + .addIntegerOption((option) => option + .setName("playdate") + .setDescription("Selects a playdate to export") + .setAutocomplete(true) + ) + .addBooleanOption(option => option + .setName("future-dates") + .setDescription("Exports the next playdates as a ical file") + ) + ); + } + + async execute(interaction: ChatInputCommandInteraction): Promise { + const group = GroupSelection.getGroup(interaction); + + switch (interaction.options.getSubcommand()) { + case "create": + await this.create(interaction, group); + break; + case "remove": + await this.delete(interaction, group); + break; + case "list": + await this.list(interaction, group); + break; + case "import": + await this.import(interaction, group); + break; + case "export": + await this.export(interaction, group); + break; + default: + throw new UserError("This subcommand is not yet implemented."); + } + } + + async create(interaction: CommandInteraction, group: GroupModel): Promise { + const fromDate = Date.parse(interaction.options.get("from")?.value ?? ''); + const toDate = Date.parse(interaction.options.get("to")?.value ?? ''); + + if (isNaN(fromDate)) { + throw new UserError("No date or invalid date format for the from parameter."); } - private async list(interaction: ChatInputCommandInteraction, group: GroupModel) { - const playdates = Container.get(PlaydateRepository.name).findFromGroup(group); + if (isNaN(toDate)) { + throw new UserError("No date or invalid date format for the to parameter."); + } - const embed = new EmbedBuilder() - .setTitle("The next playdates:") - .setFields( - playdates.map((playdate) => - { - return { - name: `${time(playdate.from_time, 'F')} - ${time(playdate.to_time, 'F')}`, - value: `${time(playdate.from_time, 'R')}` - } - }) - ) - .setFooter({ - text: `Group: ${group.name}` - }) - - await interaction.reply({ - embeds: [ - embed - ], - flags: MessageFlags.Ephemeral, + if (fromDate > toDate) { + throw new UserError("The to-date can't be earlier than the from-date"); + } + + const playdateRepo = Container.get(PlaydateRepository.name); + + const collidingTimes = playdateRepo.findPlaydatesInRange(fromDate, toDate, group); + if (collidingTimes.length > 0) { + throw new UserError("The playdate collides with another playdate. Please either remove the old one or choose a different time.") + } + + const playdate: Partial = { + group: group, + from_time: new Date(fromDate), + to_time: new Date(toDate), + } + + playdateRepo.create(playdate); + + const embed = new EmbedBuilder() + .setTitle("Created a play-date.") + .setDescription(":white_check_mark: Your playdate has been created! You and your group get notified, when its time.") + .setFields({ + name: "Created playdate", + value: `${time(new Date(fromDate), 'F')} - ${time(new Date(toDate), 'F')}`, + }) + .setFooter({ + text: `Group: ${group.name}` + }) + + await interaction.reply({ + embeds: [ + embed + ], + flags: MessageFlags.Ephemeral, + }) + } + + async handleAutocomplete(interaction: AutocompleteInteraction): Promise { + const option = interaction.options.getFocused(true); + if (option.name == "group") { + await GroupSelection.handleAutocomplete(interaction); + return; + } + + if (option.name != 'playdate') { + return; + } + + const group = GroupSelection.getGroup(interaction); + + const playdates = Container.get(PlaydateRepository.name).findFromGroup(group); + await interaction.respond( + playdates.map(playdate => { + return { + name: `${playdate.from_time.toLocaleString()} - ${playdate.to_time.toLocaleString()}`, + value: playdate.id + } + }) + ) + } + + private async list(interaction: ChatInputCommandInteraction, group: GroupModel) { + const playdates = Container.get(PlaydateRepository.name).findFromGroup(group); + + const embed = new EmbedBuilder() + .setTitle("The next playdates:") + .setFields( + playdates.map((playdate) => { + return { + name: `${time(playdate.from_time, 'F')} - ${time(playdate.to_time, 'F')}`, + value: `${time(playdate.from_time, 'R')}` + } }) + ) + .setFooter({ + text: `Group: ${group.name}` + }) + + await interaction.reply({ + embeds: [ + embed + ], + flags: MessageFlags.Ephemeral, + }) + } + + private async delete(interaction: ChatInputCommandInteraction, group: GroupModel): Promise { + const playdateId = interaction.options.getInteger("playdate", true) + + const repo = Container.get(PlaydateRepository.name); + const selected = repo.getById(playdateId); + if (!selected) { + throw new UserError("No playdate found"); + } + + if (selected.group?.id != group.id) { + throw new UserError("No playdate found"); + } + + repo.delete(playdateId); + + const embed = new EmbedBuilder() + .setTitle("Playdate deleted") + .setDescription( + `:x: Deleted \`${selected.from_time.toLocaleString()} - ${selected.to_time.toLocaleString()}\`` + ) + .setFooter({ + text: `Group: ${group.name}` + }) + + await interaction.reply({ + embeds: [ + embed + ], + flags: MessageFlags.Ephemeral, + }) + } + + private async import(interaction: ChatInputCommandInteraction, group: GroupModel): Promise { + const file = interaction.options.getAttachment('file', true); + const mimeType = file.contentType?.split(';')[0]; + if (mimeType !== "text/calendar") { + throw new UserError(`Invalid ical file. Got: ${mimeType}`, "Providing a valid iCal file"); } - private async delete(interaction: ChatInputCommandInteraction, group: GroupModel): Promise { - const playdateId = interaction.options.getInteger("playdate", true) - - const repo = Container.get(PlaydateRepository.name); - const selected = repo.getById(playdateId); - if (!selected) { - throw new UserError("No playdate found"); - } - - if (selected.group?.id != group.id) { - throw new UserError("No playdate found"); - } - - repo.delete(playdateId); - - const embed = new EmbedBuilder() - .setTitle("Playdate deleted") - .setDescription( - `:x: Deleted \`${selected.from_time.toLocaleString()} - ${selected.to_time.toLocaleString()}\`` - ) - .setFooter({ - text: `Group: ${group.name}` - }) - - await interaction.reply({ - embeds: [ - embed - ], - flags: MessageFlags.Ephemeral, - }) + await interaction.deferReply({ + flags: MessageFlags.Ephemeral + }); + + const playdateRepo = Container.get(PlaydateRepository.name); + + const icalFile = await ical.async.fromURL(file.url); + const playdates: PlaydateModel[] = []; + for (const event of Object.values(icalFile)) { + if (event.type !== 'VEVENT') { + continue; + } + + const playdate: Partial = { + group: group, + from_time: event.start, + to_time: event.end + } + + const id = playdateRepo.create(playdate); + playdates.push({ + id, + ...playdate + }); } + + const embed = new EmbedBuilder() + .setTitle("Imported play-dates.") + .setDescription(`:white_check_mark: Your ${playdates.length} playdates has been created! You and your group get notified, when its time.`) + .setFields({ + name: "Created playdates", + value: playdates.map((playdate) => `${time(playdate.from_time, 'F')} - ${time(playdate.to_time, 'F')}`).join('\n') + }) + .setFooter({ + text: `Group: ${group.name}` + }) + + interaction.followUp({ + embeds: [embed], + flags: MessageFlags.Ephemeral + }) + } + + private async export(interaction: ChatInputCommandInteraction, group: GroupModel): Promise { + const playdates = this.getExportTargets(interaction, group); + + const result = ics.createEvents( + playdates.map((playdate) => { + return { + start: ics.convertTimestampToArray(playdate.from_time.getTime(), ''), + startInputType: 'utc', + startOutputType: 'utc', + end: ics.convertTimestampToArray(playdate.to_time.getTime(), ''), + endInputType: 'utc', + endOutputType: 'utc', + title: `PnP with ${group.name}`, + status: "CONFIRMED", + busyStatus: "FREE", + categories: ['PnP'] + } + }) + ); + if (!result.value) { + throw new Error("Failed creating ics file...") + } + const attachment = new AttachmentBuilder(Buffer.from(result.value), { + name: "ICSExport.ics", + description: "Your export :)" + }); + interaction.reply({ + files: [attachment], + flags: MessageFlags.Ephemeral + }) + } + + private getExportTargets(interaction: ChatInputCommandInteraction, group: GroupModel): PlaydateModel[] { + + const repo = Container.get(PlaydateRepository.name); + const nextPlaydates = interaction.options.getBoolean("future-dates") ?? false; + if (nextPlaydates) { + return repo.findPlaydatesInRange(new Date()); + } + + const playdateId = interaction.options.getInteger('playdate'); + if (!playdateId) { + throw new UserError("Nothing to export", "Please specify what you want to export."); + } + + const playdate = repo.getById(playdateId); + if (!playdate) { + throw new UserError("Specified playdate id is invalid"); + } + + return [playdate]; + } } \ No newline at end of file diff --git a/source/Discord/DiscordClient.ts b/source/Discord/DiscordClient.ts index c0d36ee..8825ad5 100644 --- a/source/Discord/DiscordClient.ts +++ b/source/Discord/DiscordClient.ts @@ -1,67 +1,77 @@ import { - Client, - GatewayIntentBits, - Events, - ActivityType, REST + Client, + GatewayIntentBits, + Events, + ActivityType, REST } from "discord.js"; import Commands from "./Commands/Commands"; import {Container} from "../Container/Container"; import {Logger} from "log4js"; import {InteractionRouter} from "./InteractionRouter"; +import {CommandDeployer} from "./CommandDeployer"; export class DiscordClient { - private readonly client: Client; + private readonly client: Client; - public get Client(): Client { - return this.client; - } + public get Client(): Client { + return this.client; + } - public get Commands(): Commands { - return this.router.commands - } + public get Commands(): Commands { + return this.router.commands + } - public get RESTClient(): REST { - return this.restClient; - } + public get RESTClient(): REST { + return this.restClient; + } - public get ApplicationId(): string { - return this.applicationId; - } + public get ApplicationId(): string { + return this.applicationId; + } - constructor( - private readonly applicationId: string, - private readonly router: InteractionRouter, - private readonly restClient: REST = new REST() - ) { - this.client = new Client({ - intents: [GatewayIntentBits.Guilds] - }) - } + constructor( + private readonly applicationId: string, + private readonly router: InteractionRouter, + private readonly deployer: CommandDeployer, + private readonly logger: Logger, + private readonly restClient: REST = new REST() + ) { + this.client = new Client({ + intents: [GatewayIntentBits.Guilds] + }) + } - applyEvents() { - this.client.once(Events.ClientReady, () => { - if (!this.client.user) { - return; - } + applyEvents() { + this.client.once(Events.ClientReady, () => { + if (!this.client.user) { + return; + } - Container.get("logger").info(`Ready! Logged in as ${this.client.user.tag}`); - this.client.user.setActivity('your PnP playdates', { - type: ActivityType.Watching, - }); - }) + this.logger.info(`Ready! Logged in as ${this.client.user.tag}`); + this.client.user.setActivity('your PnP playdates', { + type: ActivityType.Watching, + }); + }) - this.client.on(Events.GuildAvailable, () => { - Container.get("logger").info("Joined Guild?") - }) + this.client.on(Events.GuildCreate, (guild) => { + this.logger.info(`Joined ${guild.name}`); + this.deployer.deployServer(guild.id); + }) + this.client.on(Events.GuildDelete, (guild) => { + this.logger.info(`Left ${guild.name}`); + }) + this.client.on(Events.GuildAvailable, (guild) => { + this.deployer.deployServer(guild.id); + }) - this.client.on(Events.InteractionCreate, this.router.route.bind(this.router)); - } + this.client.on(Events.InteractionCreate, this.router.route.bind(this.router)); + } - connect(token: string) { - this.client.login(token); - } + connect(token: string) { + this.client.login(token); + } - connectRESTClient(token: string) { - this.restClient.setToken(token); - } + connectRESTClient(token: string) { + this.restClient.setToken(token); + } } \ No newline at end of file diff --git a/source/Discord/InteractionRouter.ts b/source/Discord/InteractionRouter.ts index d40deec..407fb24 100644 --- a/source/Discord/InteractionRouter.ts +++ b/source/Discord/InteractionRouter.ts @@ -50,20 +50,20 @@ export class InteractionRouter { return InteractionRoutingType.Unrouted; } - + private async handleCommand(interaction: ChatInputCommandInteraction) { try { const command = this.commands.getCommand(interaction.commandName); if (!command) { throw new UserError(`Requested command not found.`); } - + if (!('execute' in command)) { throw new UserError(`Requested command is not setup for a chat command.`); } - + this.logger.debug(`Found chat command ${interaction.commandName}: running...`); - + await command.execute?.call(command, interaction); } catch (e: any) { this.logger.error(e) @@ -79,13 +79,13 @@ ${inlineCode(e.tryInstead)}` } } if (interaction.replied || interaction.deferred) { - await interaction.followUp({ content: userMessage, flags: MessageFlags.Ephemeral }); + await interaction.followUp({content: userMessage, flags: MessageFlags.Ephemeral}); } else { - await interaction.reply({ content: userMessage, flags: MessageFlags.Ephemeral }); + await interaction.reply({content: userMessage, flags: MessageFlags.Ephemeral}); } } } - + private async handleAutocomplete(interaction: AutocompleteInteraction) { const command = this.commands.getCommand(interaction.commandName); @@ -104,6 +104,6 @@ ${inlineCode(e.tryInstead)}` } catch (e: unknown) { Container.get('logger').error(e); } - + } } \ No newline at end of file diff --git a/source/Discord/UserError.ts b/source/Discord/UserError.ts index 7eb76cf..8277d96 100644 --- a/source/Discord/UserError.ts +++ b/source/Discord/UserError.ts @@ -1,10 +1,10 @@ export class UserError extends Error { - constructor( - message: string, - public readonly tryInstead: string|null = null - ) { - super(message); - - } + constructor( + message: string, + public readonly tryInstead: string | null = null + ) { + super(message); + + } } \ No newline at end of file diff --git a/source/Environment.ts b/source/Environment.ts index de9a103..d16b971 100644 --- a/source/Environment.ts +++ b/source/Environment.ts @@ -2,33 +2,33 @@ import dotenv from "dotenv"; import path from "node:path"; type DiscordEnvironment = { - token: string; - guildId: string; - clientId: string; + token: string; + guildId: string; + clientId: string; } export type DatabaseEnvironment = { - path: string; + path: string; } export class Environment { - get discord(): DiscordEnvironment { - return { - token: process.env.DISCORD_API_KEY ?? '', - guildId: process.env.DISCORD_GUILD_ID ?? '', - clientId: process.env.DISCORD_CLIENT_ID ?? '', - } + get discord(): DiscordEnvironment { + return { + token: process.env.DISCORD_API_KEY ?? '', + guildId: process.env.DISCORD_GUILD_ID ?? '', + clientId: process.env.DISCORD_CLIENT_ID ?? '', } - - get database(): DatabaseEnvironment { - return { - path: path.resolve(process.env.DB_PATH ?? ''), - } - } - - public setup() { - dotenv.config({ - path: path.resolve(__dirname, "../environment/.env"), - }); + } + + get database(): DatabaseEnvironment { + return { + path: path.resolve(process.env.DB_PATH ?? ''), } + } + + public setup() { + dotenv.config({ + path: path.resolve(__dirname, "../environment/.env"), + }); + } } \ No newline at end of file diff --git a/source/Events/DefaultEvents.ts b/source/Events/DefaultEvents.ts index 538cff5..f1c6924 100644 --- a/source/Events/DefaultEvents.ts +++ b/source/Events/DefaultEvents.ts @@ -10,17 +10,17 @@ export class DefaultEvents { const events: TimedEvent[] = [ new ReminderEvent() ] - + const eventHandler = Container.get(EventHandler.name); - + events.forEach((event) => { eventHandler.addTimed(event); }) } - + public static setupHandlers() { const eventHandler = Container.get(EventHandler.name); - + eventHandler.addHandler>(ElementCreatedEvent.name, sendCreatedNotificationEventHandler); } } \ No newline at end of file diff --git a/source/Events/ElementCreatedEvent.ts b/source/Events/ElementCreatedEvent.ts index 0153f68..c09af5f 100644 --- a/source/Events/ElementCreatedEvent.ts +++ b/source/Events/ElementCreatedEvent.ts @@ -2,9 +2,9 @@ import {Model} from "../Models/Model"; export class ElementCreatedEvent { constructor( - public readonly tableName: string, + public readonly tableName: string, public readonly instanceValues: Partial, - public readonly instanceId: number|bigint + public readonly instanceId: number | bigint ) { } } \ No newline at end of file diff --git a/source/Events/EventHandler.ts b/source/Events/EventHandler.ts index 6615acf..678587f 100644 --- a/source/Events/EventHandler.ts +++ b/source/Events/EventHandler.ts @@ -1,8 +1,8 @@ import cron from "node-cron"; -import {Class} from "../types/Class"; +import {Class} from "../types/Class"; export type EventConfiguration = { - name: string, + name: string, maxExecutions?: number, } @@ -14,34 +14,34 @@ export interface TimedEvent { export class EventHandler { private eventHandlers: Map = new Map(); - + constructor() { } - + public addHandler(eventName: string, handler: (event: T) => void) { if (!this.eventHandlers.has(eventName)) { this.eventHandlers.set(eventName, []); } - + this.eventHandlers.get(eventName)?.push(handler); } - + public dispatch(event: T) { const eventName = event.constructor.name; if (!this.eventHandlers.has(eventName)) { return; } - + this.eventHandlers.get(eventName)?.forEach((handler) => { handler(event); }) } - + public addTimed(event: TimedEvent) { if (!cron.validate(event.cronExpression)) { throw new Error(`Can't create event with name '${event.configuration.name}': Invalid cron expression.`) } - + cron.schedule(event.cronExpression, event.execute.bind(event), event.configuration); } } \ No newline at end of file diff --git a/source/Events/Handlers/SendCreatedNotification.ts b/source/Events/Handlers/SendCreatedNotification.ts index a258816..84266b5 100644 --- a/source/Events/Handlers/SendCreatedNotification.ts +++ b/source/Events/Handlers/SendCreatedNotification.ts @@ -23,18 +23,18 @@ export async function sendCreatedNotificationEventHandler(event: ElementCreatedE if (!playdate.group || !playdate.from_time || !playdate.to_time) { return; } - - + + const configurationHandler = new GroupConfigurationHandler( Container.get(GroupConfigurationRepository.name), playdate.group ); - + const targetChannel = configurationHandler.getConfigurationByPath('channels.newPlaydates'); if (!targetChannel) { return; } - + const channel = await Container.get(DiscordClient.name).Client.channels.fetch(targetChannel) if (!channel) { return; @@ -67,7 +67,7 @@ export async function sendCreatedNotificationEventHandler(event: ElementCreatedE embed ], allowedMentions: { - roles: [ playdate.group.role.roleid ] + roles: [playdate.group.role.roleid] } }) } \ No newline at end of file diff --git a/source/Events/ReminderEvent.ts b/source/Events/ReminderEvent.ts index 1dcfbf8..3fd740f 100644 --- a/source/Events/ReminderEvent.ts +++ b/source/Events/ReminderEvent.ts @@ -14,24 +14,24 @@ export class ReminderEvent implements TimedEvent { 1, 7 ]; - + private static REMINDER_NOTIFICATIONS = [ 'The darkness approaches. Get ready!', 'Your aid is requested once again.', 'Grab your dice and show them evil-doers how its done!', ] - + configuration: EventConfiguration = { name: "Reminders", } - + cronExpression: string = "0 9 * * *" private readonly groupConfigurationRepository: GroupConfigurationRepository private playdateRepository: PlaydateRepository private discordClient: DiscordClient - + constructor() { this.playdateRepository = Container.get(PlaydateRepository.name); this.groupConfigurationRepository = Container.get(GroupConfigurationRepository.name); @@ -40,61 +40,61 @@ export class ReminderEvent implements TimedEvent { async execute() { const today = new Date(); - today.setHours(0,0,0,0); - + today.setHours(0, 0, 0, 0); + const playdates = ReminderEvent.REMINDER_INTERVALS.flatMap((interval) => { const fromDate = new Date(today.valueOf()) fromDate.setDate(fromDate.getDate() + interval); - + const toDate = new Date(today.valueOf()) toDate.setDate(toDate.getDate() + interval); - toDate.setHours(23,59,59,999); - + toDate.setHours(23, 59, 59, 999); + return this.playdateRepository.findPlaydatesInRange(fromDate, toDate); }, this) - + const promises = playdates .map((playdate) => { if (!playdate.group) { return Promise.resolve(); } - + const configurationHandler = new GroupConfigurationHandler( this.groupConfigurationRepository, playdate.group ); - + const config = configurationHandler.getConfiguration(); const targetChannel = config.channels?.playdateReminders; - + if (!targetChannel) { return Promise.resolve(); } - + return this.sendReminder(playdate, targetChannel); }, this) - + await Promise.all(promises); } - + private async sendReminder(playdate: PlaydateModel, targetChannel: ChannelId) { if (!playdate.group) { return; } - + const channel = await this.discordClient.Client.channels.fetch(targetChannel) if (!channel) { return; } - + if (!channel.isTextBased()) { return; } - + if (!channel.isSendable()) { return; } - + const embed = new EmbedBuilder() .setTitle("Playdate reminder") .setDescription( @@ -114,7 +114,7 @@ export class ReminderEvent implements TimedEvent { embed ], allowedMentions: { - roles: [ playdate.group.role.roleid ] + roles: [playdate.group.role.roleid] } }) } diff --git a/source/Groups/GroupConfigurationHandler.ts b/source/Groups/GroupConfigurationHandler.ts index 95dc9e8..bcfdfc7 100644 --- a/source/Groups/GroupConfigurationHandler.ts +++ b/source/Groups/GroupConfigurationHandler.ts @@ -10,66 +10,67 @@ import {Nullable} from "../types/Nullable"; import {isPlainObject} from "is-plain-object"; export class GroupConfigurationHandler { - static DEFAULT_CONFIGURATION: RuntimeGroupConfiguration = { - channels: null, - locale: new Intl.Locale('en-GB'), - permissions: { - allowMemberManagingPlaydates: false - } + static DEFAULT_CONFIGURATION: RuntimeGroupConfiguration = { + channels: null, + locale: new Intl.Locale('en-GB'), + permissions: { + allowMemberManagingPlaydates: false } - - private readonly transformers: GroupConfigurationTransformers = new GroupConfigurationTransformers(); - - constructor( - private readonly repository: GroupConfigurationRepository, - private readonly group: GroupModel - ) { } + } + + private readonly transformers: GroupConfigurationTransformers = new GroupConfigurationTransformers(); + + constructor( + private readonly repository: GroupConfigurationRepository, + private readonly group: GroupModel + ) { + } - public saveConfiguration(path: string, value: string): void { - const configuration = this.repository.findConfigurationByPath(this.group, path); - - if (configuration) { - this.repository.update( - { - ...configuration, - value: value - } - ) - return; - } - - this.repository.create({ - group: this.group, - key: path, - value: value, - }); - } - - public getConfiguration(): RuntimeGroupConfiguration { - return deepmerge(GroupConfigurationHandler.DEFAULT_CONFIGURATION, this.getDatabaseConfiguration(), { - isMergeableObject: isPlainObject - }); - } - - public getConfigurationByPath(path: string): Nullable { - const configuration = this.repository.findConfigurationByPath(this.group, path); - if (!configuration) { - return null; - } + public saveConfiguration(path: string, value: string): void { + const configuration = this.repository.findConfigurationByPath(this.group, path); - return this.transformers.getValue(configuration); + if (configuration) { + this.repository.update( + { + ...configuration, + value: value + } + ) + return; } - - private getDatabaseConfiguration(): Partial { - const values = this.repository.findGroupConfigurations(this.group); - const configuration: Partial = {}; - - values.forEach((configValue) => { - const value = this.transformers.getValue(configValue); - setPath(configuration, configValue.key, value); - }) - - return configuration; + + this.repository.create({ + group: this.group, + key: path, + value: value, + }); + } + + public getConfiguration(): RuntimeGroupConfiguration { + return deepmerge(GroupConfigurationHandler.DEFAULT_CONFIGURATION, this.getDatabaseConfiguration(), { + isMergeableObject: isPlainObject + }); + } + + public getConfigurationByPath(path: string): Nullable { + const configuration = this.repository.findConfigurationByPath(this.group, path); + if (!configuration) { + return null; } + + return this.transformers.getValue(configuration); + } + + private getDatabaseConfiguration(): Partial { + const values = this.repository.findGroupConfigurations(this.group); + const configuration: Partial = {}; + + values.forEach((configValue) => { + const value = this.transformers.getValue(configValue); + setPath(configuration, configValue.key, value); + }) + + return configuration; + } } \ No newline at end of file diff --git a/source/Groups/GroupConfigurationRenderer.ts b/source/Groups/GroupConfigurationRenderer.ts index 524f1f0..1c4bd09 100644 --- a/source/Groups/GroupConfigurationRenderer.ts +++ b/source/Groups/GroupConfigurationRenderer.ts @@ -1,371 +1,372 @@ import {GroupConfigurationTransformers, TransformerType} from "./GroupConfigurationTransformers"; import {GroupConfigurationHandler} from "./GroupConfigurationHandler"; import { - ActionRowBuilder, - AnySelectMenuInteraction, - ButtonBuilder, - ButtonStyle, channelMention, - ChannelSelectMenuBuilder, ChannelType, - ChatInputCommandInteraction, EmbedBuilder, inlineCode, Interaction, - InteractionReplyOptions, - InteractionUpdateOptions, italic, MessageFlags, - StringSelectMenuBuilder, - StringSelectMenuOptionBuilder, UserSelectMenuBuilder + ActionRowBuilder, + AnySelectMenuInteraction, + ButtonBuilder, + ButtonStyle, channelMention, + ChannelSelectMenuBuilder, ChannelType, + ChatInputCommandInteraction, EmbedBuilder, inlineCode, Interaction, + InteractionReplyOptions, + InteractionUpdateOptions, italic, MessageFlags, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, UserSelectMenuBuilder } from "discord.js"; import {Logger} from "log4js"; import {Container} from "../Container/Container"; import {Nullable} from "../types/Nullable"; import { - MentionableSelectMenuBuilder, - MessageActionRowComponentBuilder, - RoleSelectMenuBuilder + MentionableSelectMenuBuilder, + MessageActionRowComponentBuilder, + RoleSelectMenuBuilder } from "@discordjs/builders"; import {ChannelId} from "../types/DiscordTypes"; import {IconCache} from "../Icons/IconCache"; type UIElementCollection = Record; type UIElement = { - label: string, - key: string, - description: string, - childrenElements?: UIElementCollection, - isConfiguration?: true + label: string, + key: string, + description: string, + childrenElements?: UIElementCollection, + isConfiguration?: true } export class GroupConfigurationRenderer { - private static MOVETO_COMMAND = 'moveto_'; - private static SETVALUE_COMMAND = 'setvalue_'; - private static MOVEBACK_COMMAND = 'back'; - - private breadcrumbs: string[] = []; - - private static UI_ELEMENTS : UIElementCollection = { - channels: { - label: 'Channels', - key: 'channels', - description: "Provides settings to define in what channels the bot sends messages, when not directly interacting with it.", - childrenElements: { - newPlaydates: { - label: 'New Playdates', - key: 'newPlaydates', - description: "Sets the channel, where the group get notified when new Playdates are set.", - isConfiguration: true - }, - playdateReminders: { - label: 'Playdate Reminders', - key: 'playdateReminders', - description: "Sets the channel, where the group gets reminded of upcoming playdates.", - isConfiguration: true - } - } + private static MOVETO_COMMAND = 'moveto_'; + private static SETVALUE_COMMAND = 'setvalue_'; + private static MOVEBACK_COMMAND = 'back'; + + private breadcrumbs: string[] = []; + + private static UI_ELEMENTS: UIElementCollection = { + channels: { + label: 'Channels', + key: 'channels', + description: "Provides settings to define in what channels the bot sends messages, when not directly interacting with it.", + childrenElements: { + newPlaydates: { + label: 'New Playdates', + key: 'newPlaydates', + description: "Sets the channel, where the group get notified when new Playdates are set.", + isConfiguration: true }, - locale: { - label: "Locale", - key: 'locale', - description: "Provides locale to be used for this group. This mostly sets how the dates are displayed, but this can also be later used for translations.", - isConfiguration: true - }, - permissions: { - label: "Permissions", - key: "permissions", - description: "Allows customization, how the members are allowed to interact with the data stored in the group.", - childrenElements: { - allowMemberManagingPlaydates: { - label: "Manage Playdates", - key: "allowMemberManagingPlaydates", - description: "Defines if the members are allowed to manage playdates like adding or deleting them.", - isConfiguration: true - } - } + playdateReminders: { + label: 'Playdate Reminders', + key: 'playdateReminders', + description: "Sets the channel, where the group gets reminded of upcoming playdates.", + isConfiguration: true } + } + }, + locale: { + label: "Locale", + key: 'locale', + description: "Provides locale to be used for this group. This mostly sets how the dates are displayed, but this can also be later used for translations.", + isConfiguration: true + }, + permissions: { + label: "Permissions", + key: "permissions", + description: "Allows customization, how the members are allowed to interact with the data stored in the group.", + childrenElements: { + allowMemberManagingPlaydates: { + label: "Manage Playdates", + key: "allowMemberManagingPlaydates", + description: "Defines if the members are allowed to manage playdates like adding or deleting them.", + isConfiguration: true + } + } } - - constructor( - private readonly configurationHandler: GroupConfigurationHandler, - private readonly transformers: GroupConfigurationTransformers, - ) {} - - public async setup(interaction: ChatInputCommandInteraction) { - let response = await interaction.reply(this.getReplyOptions()); - let exit = false; - let eventResponse; - const filter = (i: Interaction) => i.user.id === interaction.user.id; - do { - - if (eventResponse) { - response = await eventResponse.update(this.getReplyOptions()); - } - - try { - eventResponse = await response.resource?.message?.awaitMessageComponent({ - dispose: true, - filter: filter, - time: 60_000 - }); - } catch (_: unknown) { - break; - } - - if (!eventResponse || eventResponse.customId === 'exit') { - exit = true; - continue; - } - - if (eventResponse.customId === GroupConfigurationRenderer.MOVEBACK_COMMAND) { - this.breadcrumbs.pop() - continue; - } - - if (eventResponse.customId.startsWith(GroupConfigurationRenderer.MOVETO_COMMAND)) { - this.breadcrumbs.push( - eventResponse.customId.substring(GroupConfigurationRenderer.MOVETO_COMMAND.length) - ) - continue; - } - - if (eventResponse.customId.startsWith(GroupConfigurationRenderer.SETVALUE_COMMAND)) { - this.handleSelection(eventResponse); - continue; - } - - } while(!exit); - - if (eventResponse) { - try { - await eventResponse.update( - this.getReplyOptions() - ); - } catch (_) { - - } - await eventResponse.deleteReply(); - - return; - } - - const message = response.resource?.message - if (!message) { - return; - } - - if (message.deletable) { - await message.delete() - } - } - - private getReplyOptions(): InteractionUpdateOptions & InteractionReplyOptions & { withResponse: true } { - const embed = this.createEmbed(); - const icons = Container.get(IconCache.name); - embed.setAuthor({ - name: "/ " + this.breadcrumbs.join(" / ") + } + + constructor( + private readonly configurationHandler: GroupConfigurationHandler, + private readonly transformers: GroupConfigurationTransformers, + ) { + } + + public async setup(interaction: ChatInputCommandInteraction) { + let response = await interaction.reply(this.getReplyOptions()); + let exit = false; + let eventResponse; + const filter = (i: Interaction) => i.user.id === interaction.user.id; + do { + + if (eventResponse) { + response = await eventResponse.update(this.getReplyOptions()); + } + + try { + eventResponse = await response.resource?.message?.awaitMessageComponent({ + dispose: true, + filter: filter, + time: 60_000 }); - - const exitButton = new ButtonBuilder() - .setLabel("Exit") - .setStyle(ButtonStyle.Danger) - .setCustomId("exit") - .setEmoji(icons.get("door_open_solid_white") ?? ''); - - const actionrow = new ActionRowBuilder() - - if (this.breadcrumbs.length > 0) { - const backButton = new ButtonBuilder() - .setLabel("Back") - .setStyle(ButtonStyle.Secondary) - .setCustomId(GroupConfigurationRenderer.MOVEBACK_COMMAND) - .setEmoji(icons.get("angle_left_solid") ?? ''); - - actionrow.addComponents(backButton) - } - actionrow.addComponents(exitButton) - - return { - content: "", - embeds: [embed], - components: [...this.createActionRowBuildersForMenu(), actionrow], - withResponse: true, - flags: MessageFlags.Ephemeral - }; + } catch (_: unknown) { + break; + } + + if (!eventResponse || eventResponse.customId === 'exit') { + exit = true; + continue; + } + + if (eventResponse.customId === GroupConfigurationRenderer.MOVEBACK_COMMAND) { + this.breadcrumbs.pop() + continue; + } + + if (eventResponse.customId.startsWith(GroupConfigurationRenderer.MOVETO_COMMAND)) { + this.breadcrumbs.push( + eventResponse.customId.substring(GroupConfigurationRenderer.MOVETO_COMMAND.length) + ) + continue; + } + + if (eventResponse.customId.startsWith(GroupConfigurationRenderer.SETVALUE_COMMAND)) { + this.handleSelection(eventResponse); + continue; + } + + } while (!exit); + + if (eventResponse) { + try { + await eventResponse.update( + this.getReplyOptions() + ); + } catch (_) { + + } + await eventResponse.deleteReply(); + + return; } - - private createEmbed(): EmbedBuilder { - const {currentElement} = this.findCurrentUI(); - - if (currentElement === null) { - return new EmbedBuilder() - .setTitle("Group Configuration") - .setDescription("This UI allows you to change settings for your group.") - } - - const embed = new EmbedBuilder() - .setTitle(currentElement?.label ?? '') - .setDescription(currentElement?.description ?? ''); - - if (currentElement?.isConfiguration ?? false) { - embed.addFields( - { name: "Current Value", value: this.getCurrentValueAsUI(), inline: false } - ) - } - - return embed; + + const message = response.resource?.message + if (!message) { + return; + } + + if (message.deletable) { + await message.delete() + } + } + + private getReplyOptions(): InteractionUpdateOptions & InteractionReplyOptions & { withResponse: true } { + const embed = this.createEmbed(); + const icons = Container.get(IconCache.name); + embed.setAuthor({ + name: "/ " + this.breadcrumbs.join(" / ") + }); + + const exitButton = new ButtonBuilder() + .setLabel("Exit") + .setStyle(ButtonStyle.Danger) + .setCustomId("exit") + .setEmoji(icons.get("door_open_solid_white") ?? ''); + + const actionrow = new ActionRowBuilder() + + if (this.breadcrumbs.length > 0) { + const backButton = new ButtonBuilder() + .setLabel("Back") + .setStyle(ButtonStyle.Secondary) + .setCustomId(GroupConfigurationRenderer.MOVEBACK_COMMAND) + .setEmoji(icons.get("angle_left_solid") ?? ''); + + actionrow.addComponents(backButton) + } + actionrow.addComponents(exitButton) + + return { + content: "", + embeds: [embed], + components: [...this.createActionRowBuildersForMenu(), actionrow], + withResponse: true, + flags: MessageFlags.Ephemeral + }; + } + + private createEmbed(): EmbedBuilder { + const {currentElement} = this.findCurrentUI(); + + if (currentElement === null) { + return new EmbedBuilder() + .setTitle("Group Configuration") + .setDescription("This UI allows you to change settings for your group.") + } + + const embed = new EmbedBuilder() + .setTitle(currentElement?.label ?? '') + .setDescription(currentElement?.description ?? ''); + + if (currentElement?.isConfiguration ?? false) { + embed.addFields( + {name: "Current Value", value: this.getCurrentValueAsUI(), inline: false} + ) + } + + return embed; + } + + private getCurrentValueAsUI(): string { + const path = this.breadcrumbs.join("."); + const value = this.configurationHandler.getConfigurationByPath(path); + + if (value === undefined) return italic("None"); + + const type = this.transformers.getTransformerType(path); + + if (type === undefined) { + throw new Error("Could not find the type for " + path); } - - private getCurrentValueAsUI(): string { - const path = this.breadcrumbs.join("."); - const value = this.configurationHandler.getConfigurationByPath(path); - - if (value === undefined) return italic("None"); - - const type = this.transformers.getTransformerType(path); - - if (type === undefined) { - throw new Error("Could not find the type for " + path); - } - const displaynames = new Intl.DisplayNames(["en"], { type: "language" }); - - switch (type) { - case TransformerType.Locale: - if (!value) { - return inlineCode("Default"); - } - return displaynames.of((value)?.baseName) ?? "Unknown"; - case TransformerType.Channel: - if (!value) { - return inlineCode("None"); - } - return channelMention(value); - case TransformerType.PermissionBoolean: - return value ? "Allowed" : "Disallowed" - - default: - return "None"; - } - } - - private createActionRowBuildersForMenu() : ActionRowBuilder[] { - const {currentCollection, currentElement} = this.findCurrentUI(); - const icons = Container.get(IconCache.name); + const displaynames = new Intl.DisplayNames(["en"], {type: "language"}); - if (currentElement?.isConfiguration ?? false) { - return [ - new ActionRowBuilder() - .addComponents(this.getSelectForBreadcrumbs()) - ] + switch (type) { + case TransformerType.Locale: + if (!value) { + return inlineCode("Default"); } - - return [ - new ActionRowBuilder() - .setComponents( - ...Object.values(currentCollection).map(elem => new ButtonBuilder() - .setLabel(` ${elem.label}`) - .setStyle(ButtonStyle.Primary) - .setCustomId(GroupConfigurationRenderer.MOVETO_COMMAND + elem.key) - .setEmoji(icons.get(elem.isConfiguration ? 'pen_solid' : "folder_solid") ?? '') - ) - ) + return displaynames.of((value)?.baseName) ?? "Unknown"; + case TransformerType.Channel: + if (!value) { + return inlineCode("None"); + } + return channelMention(value); + case TransformerType.PermissionBoolean: + return value ? "Allowed" : "Disallowed" + + default: + return "None"; + } + } + + private createActionRowBuildersForMenu(): ActionRowBuilder[] { + const {currentCollection, currentElement} = this.findCurrentUI(); + const icons = Container.get(IconCache.name); + + if (currentElement?.isConfiguration ?? false) { + return [ + new ActionRowBuilder() + .addComponents(this.getSelectForBreadcrumbs()) + ] + } + + return [ + new ActionRowBuilder() + .setComponents( + ...Object.values(currentCollection).map(elem => new ButtonBuilder() + .setLabel(` ${elem.label}`) + .setStyle(ButtonStyle.Primary) + .setCustomId(GroupConfigurationRenderer.MOVETO_COMMAND + elem.key) + .setEmoji(icons.get(elem.isConfiguration ? 'pen_solid' : "folder_solid") ?? '') + ) + ) + ] + } + + private getSelectForBreadcrumbs(): ChannelSelectMenuBuilder | MentionableSelectMenuBuilder | RoleSelectMenuBuilder | StringSelectMenuBuilder | UserSelectMenuBuilder { + const breadcrumbPath = this.breadcrumbs.join('.') + const transformerType = this.transformers.getTransformerType(breadcrumbPath); + if (transformerType === undefined) { + throw new Error(`Can not find transformer type for ${breadcrumbPath}`) + } + + switch (transformerType) { + case TransformerType.Locale: + const options = [ + 'en-US', + 'fr-FR', + 'it-IT', + 'de-DE' ] - } - - private getSelectForBreadcrumbs(): ChannelSelectMenuBuilder | MentionableSelectMenuBuilder | RoleSelectMenuBuilder | StringSelectMenuBuilder | UserSelectMenuBuilder { - const breadcrumbPath = this.breadcrumbs.join('.') - const transformerType = this.transformers.getTransformerType(breadcrumbPath); - if (transformerType === undefined) { - throw new Error(`Can not find transformer type for ${breadcrumbPath}`) - } - - switch (transformerType) { - case TransformerType.Locale: - const options = [ - 'en-US', - 'fr-FR', - 'it-IT', - 'de-DE' - ] - const displaynames = new Intl.DisplayNames(["en"], { type: "language" }); - return new StringSelectMenuBuilder() - .setCustomId(GroupConfigurationRenderer.SETVALUE_COMMAND + breadcrumbPath) - .setOptions( - options.map(intl => new StringSelectMenuOptionBuilder() - .setLabel(displaynames.of(intl) ?? '') - .setValue(intl) - ) - ) - case TransformerType.Channel: - return new ChannelSelectMenuBuilder() - .setCustomId(GroupConfigurationRenderer.SETVALUE_COMMAND + breadcrumbPath) - .setChannelTypes(ChannelType.GuildText) - .setPlaceholder("New Value"); - case TransformerType.PermissionBoolean: - return new StringSelectMenuBuilder() - .setCustomId(GroupConfigurationRenderer.SETVALUE_COMMAND + breadcrumbPath) - .setOptions( - [ - { - label: "Allow", - value: "1" - }, - { - label: "Disallow", - value: "0" - } - ] - ) - - default: - return new StringSelectMenuBuilder() - .setCustomId("...") - .setOptions( - new StringSelectMenuOptionBuilder() - .setLabel("Nothing to see here") - .setValue("0") - ) - } - } - - private handleSelection(interaction: AnySelectMenuInteraction) { - const path = interaction.customId.substring(GroupConfigurationRenderer.SETVALUE_COMMAND.length); - - const savingValue = this.getSaveValue(interaction, path); - Container.get("logger").debug(`Saving '${savingValue}' to '${path}'`); - - this.configurationHandler.saveConfiguration(path, savingValue); - } - - private getSaveValue(interaction: AnySelectMenuInteraction, path: string): string { - const transformerType = this.transformers.getTransformerType(path); - if (transformerType === undefined || transformerType === null) { - throw new Error(`Can not find transformer type for ${path}`) - } + const displaynames = new Intl.DisplayNames(["en"], {type: "language"}); + return new StringSelectMenuBuilder() + .setCustomId(GroupConfigurationRenderer.SETVALUE_COMMAND + breadcrumbPath) + .setOptions( + options.map(intl => new StringSelectMenuOptionBuilder() + .setLabel(displaynames.of(intl) ?? '') + .setValue(intl) + ) + ) + case TransformerType.Channel: + return new ChannelSelectMenuBuilder() + .setCustomId(GroupConfigurationRenderer.SETVALUE_COMMAND + breadcrumbPath) + .setChannelTypes(ChannelType.GuildText) + .setPlaceholder("New Value"); + case TransformerType.PermissionBoolean: + return new StringSelectMenuBuilder() + .setCustomId(GroupConfigurationRenderer.SETVALUE_COMMAND + breadcrumbPath) + .setOptions( + [ + { + label: "Allow", + value: "1" + }, + { + label: "Disallow", + value: "0" + } + ] + ) - switch (transformerType) { - case TransformerType.Locale: - case TransformerType.Channel: - case TransformerType.PermissionBoolean: - return interaction.values.join('; '); - default: - throw new Error("Unhandled select menu"); - } + default: + return new StringSelectMenuBuilder() + .setCustomId("...") + .setOptions( + new StringSelectMenuOptionBuilder() + .setLabel("Nothing to see here") + .setValue("0") + ) } - - private findCurrentUI(): {currentElement: Nullable, currentCollection: UIElementCollection } { - let currentCollection: UIElementCollection = GroupConfigurationRenderer.UI_ELEMENTS; - let currentElement: Nullable = null; + } - for (const breadcrumb of this.breadcrumbs) { - currentElement = currentCollection[breadcrumb]; + private handleSelection(interaction: AnySelectMenuInteraction) { + const path = interaction.customId.substring(GroupConfigurationRenderer.SETVALUE_COMMAND.length); - if (currentElement.isConfiguration ?? false) { - break; - } + const savingValue = this.getSaveValue(interaction, path); + Container.get("logger").debug(`Saving '${savingValue}' to '${path}'`); - currentCollection = currentElement.childrenElements ?? {}; - } - - return { - currentElement, - currentCollection, - } + this.configurationHandler.saveConfiguration(path, savingValue); + } + + private getSaveValue(interaction: AnySelectMenuInteraction, path: string): string { + const transformerType = this.transformers.getTransformerType(path); + if (transformerType === undefined || transformerType === null) { + throw new Error(`Can not find transformer type for ${path}`) } + + switch (transformerType) { + case TransformerType.Locale: + case TransformerType.Channel: + case TransformerType.PermissionBoolean: + return interaction.values.join('; '); + default: + throw new Error("Unhandled select menu"); + } + } + + private findCurrentUI(): { currentElement: Nullable, currentCollection: UIElementCollection } { + let currentCollection: UIElementCollection = GroupConfigurationRenderer.UI_ELEMENTS; + let currentElement: Nullable = null; + + for (const breadcrumb of this.breadcrumbs) { + currentElement = currentCollection[breadcrumb]; + + if (currentElement.isConfiguration ?? false) { + break; + } + + currentCollection = currentElement.childrenElements ?? {}; + } + + return { + currentElement, + currentCollection, + } + } } \ No newline at end of file diff --git a/source/Groups/GroupConfigurationTransformers.ts b/source/Groups/GroupConfigurationTransformers.ts index afa3904..4fd59c0 100644 --- a/source/Groups/GroupConfigurationTransformers.ts +++ b/source/Groups/GroupConfigurationTransformers.ts @@ -4,63 +4,63 @@ import {Nullable} from "../types/Nullable"; import {ArrayUtils} from "../Utilities/ArrayUtils"; export enum TransformerType { - Locale, - Channel, - PermissionBoolean, + Locale, + Channel, + PermissionBoolean, } type GroupConfigurationTransformer = { - path: string[]; - type: TransformerType, + path: string[]; + type: TransformerType, } -export type GroupConfigurationResult = - ChannelId | Intl.Locale | boolean +export type GroupConfigurationResult = + ChannelId | Intl.Locale | boolean export class GroupConfigurationTransformers { - static TRANSFORMERS: GroupConfigurationTransformer[] = [ - { - path: ['channels', 'newPlaydates'], - type: TransformerType.Channel, - }, - { - path: ['channels', 'playdateReminders'], - type: TransformerType.Channel, - }, - { - path: ['locale'], - type: TransformerType.Locale, - }, - { - path: ['permissions', 'allowMemberManagingPlaydates'], - type: TransformerType.PermissionBoolean - } - ]; - - public getValue(configValue: GroupConfigurationModel): GroupConfigurationResult { - const transformerType = this.getTransformerType(configValue.key); - if (transformerType === undefined || transformerType === null) { - throw new Error(`Can't find transformer for ${configValue.key}`); - } - - switch (transformerType) { - case TransformerType.Locale: - return new Intl.Locale(configValue.value) - case TransformerType.Channel: - return configValue.value; - case TransformerType.PermissionBoolean: - return configValue.value === '1'; - } + static TRANSFORMERS: GroupConfigurationTransformer[] = [ + { + path: ['channels', 'newPlaydates'], + type: TransformerType.Channel, + }, + { + path: ['channels', 'playdateReminders'], + type: TransformerType.Channel, + }, + { + path: ['locale'], + type: TransformerType.Locale, + }, + { + path: ['permissions', 'allowMemberManagingPlaydates'], + type: TransformerType.PermissionBoolean } - - public getTransformerType(configKey: string): Nullable { - const path = configKey.split('.'); - return GroupConfigurationTransformers.TRANSFORMERS.find( - transformer => { - return ArrayUtils.arraysEqual(transformer.path, path); - } - )?.type; + ]; + + public getValue(configValue: GroupConfigurationModel): GroupConfigurationResult { + const transformerType = this.getTransformerType(configValue.key); + if (transformerType === undefined || transformerType === null) { + throw new Error(`Can't find transformer for ${configValue.key}`); } - - + + switch (transformerType) { + case TransformerType.Locale: + return new Intl.Locale(configValue.value) + case TransformerType.Channel: + return configValue.value; + case TransformerType.PermissionBoolean: + return configValue.value === '1'; + } + } + + public getTransformerType(configKey: string): Nullable { + const path = configKey.split('.'); + return GroupConfigurationTransformers.TRANSFORMERS.find( + transformer => { + return ArrayUtils.arraysEqual(transformer.path, path); + } + )?.type; + } + + } \ No newline at end of file diff --git a/source/Groups/RuntimeGroupConfiguration.d.ts b/source/Groups/RuntimeGroupConfiguration.d.ts index 3aba014..5e6e10a 100644 --- a/source/Groups/RuntimeGroupConfiguration.d.ts +++ b/source/Groups/RuntimeGroupConfiguration.d.ts @@ -2,16 +2,16 @@ import {ChannelId} from "../types/DiscordTypes"; import {Nullable} from "../types/Nullable"; export type RuntimeGroupConfiguration = { - channels: Nullable, - locale: Intl.Locale, - permissions: PermissionRuntimeGroupConfiguration + channels: Nullable, + locale: Intl.Locale, + permissions: PermissionRuntimeGroupConfiguration }; export type ChannelRuntimeGroupConfiguration = { - newPlaydates: ChannelId, - playdateReminders: ChannelId + newPlaydates: ChannelId, + playdateReminders: ChannelId } export type PermissionRuntimeGroupConfiguration = { - allowMemberManagingPlaydates: boolean + allowMemberManagingPlaydates: boolean } \ No newline at end of file diff --git a/source/Icons/IconCache.ts b/source/Icons/IconCache.ts index 814c408..b608323 100644 --- a/source/Icons/IconCache.ts +++ b/source/Icons/IconCache.ts @@ -4,30 +4,30 @@ import {Nullable} from "../types/Nullable"; export class IconCache { private existingIcons: Map | undefined; - + constructor( private readonly client: DiscordClient ) { - + } - + public get(iconName: string): Snowflake | null { if (!this.existingIcons?.has(iconName)) { return null; } - + return this.existingIcons?.get(iconName) ?? null; } - + public getEmoji(iconName: string): string { const id = this.get(iconName); - + return formatEmoji({ id, name: iconName }); } - + public async set(iconName: string, pngBuffer: Buffer) { const pngBase64 = pngBuffer.toString("base64"); const iconDataUrl = `data:image/png;base64,${pngBase64}`; @@ -42,23 +42,23 @@ export class IconCache { } ) } - + public async populate() { if (this.existingIcons != null) { return; } - + const existingEmojis: Nullable = await this.client.RESTClient.get( Routes.applicationEmojis(this.client.ApplicationId) ) - + if (!existingEmojis) { return; } - + this.existingIcons = new Map( existingEmojis.items.map((item) => { - return [ item.name, item.id ] + return [item.name, item.id] }) ) } diff --git a/source/Icons/IconDeployer.ts b/source/Icons/IconDeployer.ts index 8f72377..f1e4229 100644 --- a/source/Icons/IconDeployer.ts +++ b/source/Icons/IconDeployer.ts @@ -5,29 +5,30 @@ import {IconCache} from "./IconCache"; export class IconDeployer { static ICON_PATH = path.resolve('public/icons') - + constructor( private readonly iconCache: IconCache - ) {} - + ) { + } + public async ensureExistance() { const directory = await fs.promises.opendir(IconDeployer.ICON_PATH); const addIconPromises: Promise[] = []; for await (const dirname of directory) { - const iconName = path.basename(dirname.name, '.svg').replaceAll('-','_'); - + const iconName = path.basename(dirname.name, '.svg').replaceAll('-', '_'); + if (this.iconCache.get(iconName) !== null) { continue; } - + addIconPromises.push( this.addIcon(path.resolve(dirname.parentPath, dirname.name), iconName) ); } - + await Promise.all(addIconPromises); } - + private async addIcon(iconPath: string, iconName: string) { const svgBuffer = await fs.promises.readFile(iconPath, 'utf-8'); const pngBuffer = await new Promise(resolve => { @@ -48,8 +49,8 @@ export class IconDeployer { ) } ) - + await this.iconCache.set(iconName, pngBuffer); - + } } \ No newline at end of file diff --git a/source/Menu/MenuRenderer.ts b/source/Menu/MenuRenderer.ts new file mode 100644 index 0000000..e69de29 diff --git a/source/Models/GroupConfigurationModel.ts b/source/Models/GroupConfigurationModel.ts index 46831d1..c4f624c 100644 --- a/source/Models/GroupConfigurationModel.ts +++ b/source/Models/GroupConfigurationModel.ts @@ -2,7 +2,7 @@ import {Model} from "./Model"; import {GroupModel} from "./GroupModel"; export interface GroupConfigurationModel extends Model { - group: GroupModel; - key: string; - value: string; + group: GroupModel; + key: string; + value: string; } \ No newline at end of file diff --git a/source/Models/GroupModel.ts b/source/Models/GroupModel.ts index e659bff..64fc6f7 100644 --- a/source/Models/GroupModel.ts +++ b/source/Models/GroupModel.ts @@ -2,7 +2,7 @@ import {Model} from "./Model"; import {GuildMember, Role} from "../types/DiscordTypes"; export interface GroupModel extends Model { - name: string; - leader: GuildMember; - role: Role; + name: string; + leader: GuildMember; + role: Role; } \ No newline at end of file diff --git a/source/Models/Model.ts b/source/Models/Model.ts index 4029a15..7ecb067 100644 --- a/source/Models/Model.ts +++ b/source/Models/Model.ts @@ -1,3 +1,3 @@ export interface Model { - id: number; + id: number | bigint; } diff --git a/source/Models/PlaydateModel.ts b/source/Models/PlaydateModel.ts index 76236b2..d8e3c2b 100644 --- a/source/Models/PlaydateModel.ts +++ b/source/Models/PlaydateModel.ts @@ -3,7 +3,7 @@ import {GroupModel} from "./GroupModel"; import {Nullable} from "../types/Nullable"; export interface PlaydateModel extends Model { - group: Nullable - from_time: Date, - to_time: Date, + group: Nullable + from_time: Date, + to_time: Date, } \ No newline at end of file diff --git a/source/Repositories/GroupConfigurationRepository.ts b/source/Repositories/GroupConfigurationRepository.ts index 173037e..c8c50bc 100644 --- a/source/Repositories/GroupConfigurationRepository.ts +++ b/source/Repositories/GroupConfigurationRepository.ts @@ -1,65 +1,65 @@ import {Repository} from "./Repository"; import GroupConfiguration, {DBGroupConfiguration} from "../Database/tables/GroupConfiguration"; import {GroupConfigurationModel} from "../Models/GroupConfigurationModel"; -import { GroupModel } from "../Models/GroupModel"; +import {GroupModel} from "../Models/GroupModel"; import {Nullable} from "../types/Nullable"; import {DatabaseConnection} from "../Database/DatabaseConnection"; import {GroupRepository} from "./GroupRepository"; export class GroupConfigurationRepository extends Repository { - constructor( - protected readonly database: DatabaseConnection, - private readonly groupRepository: GroupRepository, - ) { - super( - database, - GroupConfiguration - ); - } - - public findGroupConfigurations(group: GroupModel): GroupConfigurationModel[] { - return this.database.fetchAll(` + constructor( + protected readonly database: DatabaseConnection, + private readonly groupRepository: GroupRepository, + ) { + super( + database, + GroupConfiguration + ); + } + + public findGroupConfigurations(group: GroupModel): GroupConfigurationModel[] { + return this.database.fetchAll(` SELECT * FROM groupConfiguration WHERE groupid = ?`, - group.id - ).map((config) => { - return this.convertToModelType(config, group); - }) - } - - public findConfigurationByPath(group: GroupModel, path: string): Nullable { - const result = this.database.fetch(` + group.id + ).map((config) => { + return this.convertToModelType(config, group); + }) + } + + public findConfigurationByPath(group: GroupModel, path: string): Nullable { + const result = this.database.fetch(` SELECT * FROM groupConfiguration WHERE groupid = ? AND key = ?`, - group.id, - path - ); - - if (!result) { - return null; - } - - return this.convertToModelType(result, group); + group.id, + path + ); + + if (!result) { + return null; } - - - protected convertToModelType(intermediateModel: DBGroupConfiguration | undefined, group: Nullable = null): GroupConfigurationModel { - if (!intermediateModel) { - throw new Error("No intermediate model provided"); - } - - return { - id: intermediateModel.id, - group: group ?? this.groupRepository.getById(intermediateModel.id), - key: intermediateModel.key, - value: intermediateModel.value, - } + + return this.convertToModelType(result, group); + } + + + protected convertToModelType(intermediateModel: DBGroupConfiguration | undefined, group: Nullable = null): GroupConfigurationModel { + if (!intermediateModel) { + throw new Error("No intermediate model provided"); } - - protected convertToCreateObject(instance: Partial): object { - return { - groupid: instance.group?.id ?? undefined, - key: instance.key ?? undefined, - value: instance.value ?? undefined, - } + + return { + id: intermediateModel.id, + group: group ?? this.groupRepository.getById(intermediateModel.id), + key: intermediateModel.key, + value: intermediateModel.value, } + } + + protected convertToCreateObject(instance: Partial): object { + return { + groupid: instance.group?.id ?? undefined, + key: instance.key ?? undefined, + value: instance.value ?? undefined, + } + } } \ No newline at end of file diff --git a/source/Repositories/GroupRepository.ts b/source/Repositories/GroupRepository.ts index 0e673ce..770c50d 100644 --- a/source/Repositories/GroupRepository.ts +++ b/source/Repositories/GroupRepository.ts @@ -9,94 +9,94 @@ import {Container} from "../Container/Container"; export class GroupRepository extends Repository { - constructor( - protected readonly database: DatabaseConnection, - ) { - super( - database, - Groups - ); - + constructor( + protected readonly database: DatabaseConnection, + ) { + super( + database, + Groups + ); + + } + + public findGroupByName(name: string): Nullable { + const result = this.database.fetch( + `SELECT * FROM groups WHERE name = ? LIMIT 1`, + name + ) + + if (!result) { + return undefined; } - - public findGroupByName(name: string): Nullable { - const result = this.database.fetch( - `SELECT * FROM groups WHERE name = ? LIMIT 1`, - name - ) - - if (!result) { - return undefined; - } - - return this.convertToModelType(result); - } - - public findGroupsByRoles(server: string, roleIds: string[]): GroupModel[] { - const template = roleIds.map(_roleId => '?').join(','); - - const dbResult = this.database.fetchAll(` + + return this.convertToModelType(result); + } + + public findGroupsByRoles(server: string, roleIds: string[]): GroupModel[] { + const template = roleIds.map(_roleId => '?').join(','); + + const dbResult = this.database.fetchAll(` SELECT * FROM groups WHERE server = ? AND role IN (${template}) `, - server, - ...roleIds) - - - return dbResult.map((result) => this.convertToModelType(result)); + server, + ...roleIds) + + + return dbResult.map((result) => this.convertToModelType(result)); + } + + public findGroupsByMember(member: GuildMember, onlyLeader: boolean = false) { + if (!member) { + throw new Error("Can't find member for guild: none given"); } - public findGroupsByMember(member: GuildMember, onlyLeader: boolean = false) { - if (!member) { - throw new Error("Can't find member for guild: none given"); - } - - const groups = this.findGroupsByRoles(member.guild.id, [...member.roles.cache.keys()]) - - if (!onlyLeader) { - return groups; - } - - return groups.filter((group: GroupModel) => { - return group.leader.memberid === member.id; - }) - } - - public deleteGroup(group: GroupModel): void { - this.delete(group.id); - - const repo = Container.get(PlaydateRepository.name); - const playdates = repo.findFromGroup(group, true) - playdates.forEach((playdate) => { - repo.delete(playdate.id); - }) - } - - protected convertToModelType(intermediateModel: DBGroup | undefined): GroupModel { - if (!intermediateModel) { - throw new Error("No intermediate model provided"); - } - - return { - id: intermediateModel.id, - name: intermediateModel.name, - leader: { - server: intermediateModel.server, - memberid: intermediateModel.leader - }, - role: { - server: intermediateModel.server, - roleid: intermediateModel.role - } - } + const groups = this.findGroupsByRoles(member.guild.id, [...member.roles.cache.keys()]) + + if (!onlyLeader) { + return groups; } - protected convertToCreateObject(instance: Partial): object { - return { - name: instance.name ?? '', - server: instance.role?.server ?? null, - leader: instance.leader?.memberid ?? null, - role: instance.role?.roleid ?? null, - } + return groups.filter((group: GroupModel) => { + return group.leader.memberid === member.id; + }) + } + + public deleteGroup(group: GroupModel): void { + this.delete(group.id); + + const repo = Container.get(PlaydateRepository.name); + const playdates = repo.findFromGroup(group, true) + playdates.forEach((playdate) => { + repo.delete(playdate.id); + }) + } + + protected convertToModelType(intermediateModel: DBGroup | undefined): GroupModel { + if (!intermediateModel) { + throw new Error("No intermediate model provided"); } + return { + id: intermediateModel.id, + name: intermediateModel.name, + leader: { + server: intermediateModel.server, + memberid: intermediateModel.leader + }, + role: { + server: intermediateModel.server, + roleid: intermediateModel.role + } + } + } + + protected convertToCreateObject(instance: Partial): object { + return { + name: instance.name ?? '', + server: instance.role?.server ?? null, + leader: instance.leader?.memberid ?? null, + role: instance.role?.roleid ?? null, + } + } + } \ No newline at end of file diff --git a/source/Repositories/PlaydateRepository.ts b/source/Repositories/PlaydateRepository.ts index 1e1ff63..1e337e0 100644 --- a/source/Repositories/PlaydateRepository.ts +++ b/source/Repositories/PlaydateRepository.ts @@ -7,90 +7,96 @@ import {GroupModel} from "../Models/GroupModel"; import {Nullable} from "../types/Nullable"; export class PlaydateRepository extends Repository { - - constructor( - protected readonly database: DatabaseConnection, - private readonly groupRepository: GroupRepository, - ) { - super( - database, - Playdate - ); - } - - findFromGroup(group: GroupModel, all = false) { - let sql = `SELECT * FROM ${this.schema.name} WHERE groupid = ?`; - const params = [group.id]; - - if (!all) { - sql += " AND time_from > ?" - params.push(new Date().getTime()) - } - - const finds = this.database.fetchAll( - sql, - ...params - ); - return finds.map((playdate) => this.convertToModelType(playdate, group)); - } - findPlaydatesInRange(fromDate: Date|number, toDate: Date|number, group: GroupModel | undefined = undefined) { - if (fromDate instanceof Date) { - fromDate = fromDate.getTime(); - } - if (toDate instanceof Date) { - toDate = toDate.getTime(); - } - - let sql = `SELECT * FROM ${this.schema.name} WHERE time_from > ? AND time_from < ?`; - const params = [fromDate, toDate]; - - if (group) { - sql = `${sql} AND groupid = ?` - params.push(group.id) - } - - const finds = this.database.fetchAll( - sql, - ...params - ); + constructor( + protected readonly database: DatabaseConnection, + private readonly groupRepository: GroupRepository, + ) { + super( + database, + Playdate + ); + } - return finds.map((playdate) => this.convertToModelType(playdate, group)); + findFromGroup(group: GroupModel, all = false) { + let sql = `SELECT * FROM ${this.schema.name} WHERE groupid = ?`; + const params = [group.id]; + + if (!all) { + sql += " AND time_from > ?" + params.push(new Date().getTime()) + } + + const finds = this.database.fetchAll( + sql, + ...params + ); + + return finds.map((playdate) => this.convertToModelType(playdate, group)); + } + + findPlaydatesInRange(fromDate: Date | number, toDate: Date | number | undefined = undefined, group: GroupModel | undefined = undefined) { + if (fromDate instanceof Date) { + fromDate = fromDate.getTime(); + } + if (toDate instanceof Date) { + toDate = toDate.getTime(); + } + + let sql = `SELECT * FROM ${this.schema.name} WHERE time_from > ?`; + const params = [fromDate]; + + if (toDate) { + sql = `${sql} AND time_from < ?` + params.push(toDate); } - getNextPlaydateForGroup(group: GroupModel): PlaydateModel | null { - const sql = `SELECT * FROM ${this.schema.name} WHERE groupid = ? AND time_from > ? ORDER BY time_from LIMIT 1`; - - const find = this.database.fetch( - sql, - group.id, - Date.now() - ) - - if (!find) { - return null; - } - - return this.convertToModelType(find, group) + if (group) { + sql = `${sql} AND groupid = ?` + params.push(group.id) } - - protected convertToModelType(intermediateModel: DBPlaydate | undefined, fixedGroup: Nullable = null): PlaydateModel { - if (!intermediateModel) { - throw new Error("Unable to convert the playdate model"); - } - return { - id: intermediateModel.id, - group: fixedGroup ?? this.groupRepository.getById(intermediateModel.groupid), - from_time: new Date(intermediateModel.time_from), - to_time: new Date(intermediateModel.time_to), - }; + + const finds = this.database.fetchAll( + sql, + ...params + ); + + return finds.map((playdate) => this.convertToModelType(playdate, group)); + } + + getNextPlaydateForGroup(group: GroupModel): PlaydateModel | null { + const sql = `SELECT * FROM ${this.schema.name} WHERE groupid = ? AND time_from > ? ORDER BY time_from LIMIT 1`; + + const find = this.database.fetch( + sql, + group.id, + Date.now() + ) + + if (!find) { + return null; } - - protected convertToCreateObject(instance: Partial): object { - return { - groupid: instance.group?.id ?? null, - time_from: instance.from_time?.getTime() ?? 0, - time_to: instance.to_time?.getTime() ?? 0, - } + + return this.convertToModelType(find, group) + } + + protected convertToModelType(intermediateModel: DBPlaydate | undefined, fixedGroup: Nullable = null): PlaydateModel { + if (!intermediateModel) { + throw new Error("Unable to convert the playdate model"); } + return { + id: intermediateModel.id, + group: fixedGroup ?? this.groupRepository.getById(intermediateModel.groupid), + from_time: new Date(intermediateModel.time_from), + to_time: new Date(intermediateModel.time_to), + }; + } + + protected convertToCreateObject(instance: Partial): object { + return { + groupid: instance.group?.id ?? null, + time_from: instance.from_time?.getTime() ?? 0, + time_to: instance.to_time?.getTime() ?? 0, + } + } } \ No newline at end of file diff --git a/source/Repositories/Repository.ts b/source/Repositories/Repository.ts index 6023fa0..aa73139 100644 --- a/source/Repositories/Repository.ts +++ b/source/Repositories/Repository.ts @@ -1,83 +1,85 @@ import {DatabaseConnection} from "../Database/DatabaseConnection"; import {Model} from "../Models/Model"; -import { Nullable } from "../types/Nullable"; +import {Nullable} from "../types/Nullable"; import {DatabaseDefinition} from "../Database/DatabaseDefinition"; import {Container} from "../Container/Container"; import {EventHandler} from "../Events/EventHandler"; import {ElementCreatedEvent} from "../Events/ElementCreatedEvent"; export class Repository { - - constructor( - protected readonly database: DatabaseConnection, - public readonly schema: DatabaseDefinition, - ) {} - - public create(instance: Partial): number|bigint { - const columnNames = this.schema.columns.filter((column) => { - return !column.primaryKey - }).map((column) => { - return column.name; - }); - - const createObject = this.convertToCreateObject(instance); - const keys = Object.keys(createObject); - const missingColumns = columnNames.filter((columnName) => { - return !keys.includes(columnName); - }) - - if (missingColumns.length > 0) { - throw new Error("Can't create instance, due to missing column values: " + missingColumns); - } - - const sql = `INSERT INTO ${this.schema.name}(${Object.keys(createObject).join(',')}) - VALUES (${Object.keys(createObject).map(() => "?").join(',')})`; - const result = this.database.execute(sql, ...Object.values(createObject)); - const id = result.lastInsertRowid; - - Container.get(EventHandler.name).dispatch(new ElementCreatedEvent(this.schema.name, instance, id)); - - return id; + + constructor( + protected readonly database: DatabaseConnection, + public readonly schema: DatabaseDefinition, + ) { + } + + public create(instance: Partial): number | bigint { + const columnNames = this.schema.columns.filter((column) => { + return !column.primaryKey + }).map((column) => { + return column.name; + }); + + const createObject = this.convertToCreateObject(instance); + const keys = Object.keys(createObject); + const missingColumns = columnNames.filter((columnName) => { + return !keys.includes(columnName); + }) + + if (missingColumns.length > 0) { + throw new Error("Can't create instance, due to missing column values: " + missingColumns); } - - public update(instance: Partial&{id: number}): boolean { - const columnNames = this.schema.columns.filter((column) => { - return !column.primaryKey - }).map((column) => { - return column.name; - }); - - const createObject = this.convertToCreateObject(instance); - const keys = Object.keys(createObject); - const missingColumns = columnNames.filter((columnName) => { - return !keys.includes(columnName); - }) - if (missingColumns.length > 0) { - throw new Error("Can't create instance, due to missing column values: " + missingColumns); - } + const sql = `INSERT INTO ${this.schema.name}(${Object.keys(createObject).join(',')}) + VALUES (${Object.keys(createObject).map(() => "?").join(',')})`; + const result = this.database.execute(sql, ...Object.values(createObject)); + const id = result.lastInsertRowid; - const sql = `UPDATE ${this.schema.name} + Container.get(EventHandler.name).dispatch(new ElementCreatedEvent(this.schema.name, instance, id)); + + return id; + } + + public update(instance: Partial & { id: number }): boolean { + const columnNames = this.schema.columns.filter((column) => { + return !column.primaryKey + }).map((column) => { + return column.name; + }); + + const createObject = this.convertToCreateObject(instance); + const keys = Object.keys(createObject); + const missingColumns = columnNames.filter((columnName) => { + return !keys.includes(columnName); + }) + + if (missingColumns.length > 0) { + throw new Error("Can't create instance, due to missing column values: " + missingColumns); + } + + const sql = `UPDATE ${this.schema.name} SET ${Object.keys(createObject).map((key) => `${key} = ?`).join(',')} WHERE id = ?`; - const result = this.database.execute(sql, ...Object.values(createObject), instance.id); - return result.changes > 0; - } - - public getById(id: number): Nullable { - const sql = `SELECT * FROM ${this.schema.name} WHERE id = ? LIMIT 1`; - return this.convertToModelType(this.database.fetch(sql, id)); - } - - public delete(id: number) { - const sql = `DELETE FROM ${this.schema.name} WHERE id = ?`; - return this.database.execute(sql, id); - } - - protected convertToModelType(intermediateModel: IntermediateModelType | undefined): ModelType { - return intermediateModel as unknown as ModelType; - } - protected convertToCreateObject(instance: Partial): object { - return instance; - } + const result = this.database.execute(sql, ...Object.values(createObject), instance.id); + return result.changes > 0; + } + + public getById(id: number): Nullable { + const sql = `SELECT * FROM ${this.schema.name} WHERE id = ? LIMIT 1`; + return this.convertToModelType(this.database.fetch(sql, id)); + } + + public delete(id: number) { + const sql = `DELETE FROM ${this.schema.name} WHERE id = ?`; + return this.database.execute(sql, id); + } + + protected convertToModelType(intermediateModel: IntermediateModelType | undefined): ModelType { + return intermediateModel as unknown as ModelType; + } + + protected convertToCreateObject(instance: Partial): object { + return instance; + } } \ No newline at end of file diff --git a/source/Utilities/ArrayUtils.ts b/source/Utilities/ArrayUtils.ts index e969ff2..ad9c3e9 100644 --- a/source/Utilities/ArrayUtils.ts +++ b/source/Utilities/ArrayUtils.ts @@ -1,17 +1,17 @@ export class ArrayUtils { - public static chooseRandom(array: Array):T { - const index = Math.floor(Math.random() * array.length); - return array[index]; - } + public static chooseRandom(array: Array): T { + const index = Math.floor(Math.random() * array.length); + return array[index]; + } - public static arraysEqual(a: Array, b: Array): boolean { - if (a === b) return true; - if (a == null || b == null) return false; - if (a.length !== b.length) return false; + public static arraysEqual(a: Array, b: Array): boolean { + if (a === b) return true; + if (a == null || b == null) return false; + if (a.length !== b.length) return false; - for (let i = 0; i < a.length; ++i) { - if (a[i] !== b[i]) return false; - } - return true; + for (let i = 0; i < a.length; ++i) { + if (a[i] !== b[i]) return false; } + return true; + } } \ No newline at end of file diff --git a/source/deploy.ts b/source/deploy.ts index 93b805c..0d34ce4 100644 --- a/source/deploy.ts +++ b/source/deploy.ts @@ -21,26 +21,18 @@ const logger = container.get("logger"); const client = container.get(DiscordClient.name); client.connectRESTClient(environment.discord.token) -logger.log("Deploying Commands...") - -const deployer = new CommandDeployer(client, logger); -(async () => { - await deployer.deployAvailableServers() -})() - - logger.log("Ensuring Database..."); const updater = new DatabaseUpdater(container.get(DatabaseConnection.name)); updater.ensureAvaliablity(Definitions); logger.log("Ensuring icons..."); (async () => { - const iconCache = container.get(IconCache.name); - await iconCache.populate(); - - const deployer = new IconDeployer( - iconCache - ); + const iconCache = container.get(IconCache.name); + await iconCache.populate(); - deployer.ensureExistance() + const deployer = new IconDeployer( + iconCache + ); + + deployer.ensureExistance() })() \ No newline at end of file diff --git a/source/main.ts b/source/main.ts index 4ddc5ce..488692a 100644 --- a/source/main.ts +++ b/source/main.ts @@ -14,9 +14,9 @@ DefaultEvents.setupHandlers(); const client = container.get(DiscordClient.name); client.connectRESTClient(env.discord.token); - + await container.get(IconCache.name).populate() - + client.applyEvents() client.connect(env.discord.token) })() \ No newline at end of file diff --git a/source/types/Class.ts b/source/types/Class.ts index 045a53a..d6d98c6 100644 --- a/source/types/Class.ts +++ b/source/types/Class.ts @@ -1,2 +1,2 @@ -export type Class = {constructor: {name: string}} -export type ClassNamed = {name: string} \ No newline at end of file +export type Class = { constructor: { name: string } } +export type ClassNamed = { name: string } \ No newline at end of file diff --git a/source/types/DiscordTypes.ts b/source/types/DiscordTypes.ts index d326178..006d011 100644 --- a/source/types/DiscordTypes.ts +++ b/source/types/DiscordTypes.ts @@ -1,11 +1,11 @@ export type GuildMember = { - server: string; - memberid: string; + server: string; + memberid: string; } export type Role = { - server: string; - roleid: string; + server: string; + roleid: string; }; export type ChannelId = string; \ No newline at end of file