diff --git a/.env.sample b/.env.sample index 2462f76..330a432 100644 --- a/.env.sample +++ b/.env.sample @@ -7,3 +7,6 @@ POSTGRES_URL="" GUILD_ID="" UPDATE_QUERY_CACHE_CHANNEL_ID="" + +AUTH_GOOGLE_ID="" +AUTH_GOOGLE_SECRET="" diff --git a/package.json b/package.json index 2adf66e..f40c5dc 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,10 @@ }, "dependencies": { "@vercel/postgres": "^0.9.0", + "cron": "^3.1.7", "discord.js": "^14.15.3", "dotenv": "^16.4.5", + "googleapis": "^140.0.1", "zlib-sync": "^0.1.9" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db8313a..d51d08b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,12 +11,18 @@ importers: '@vercel/postgres': specifier: ^0.9.0 version: 0.9.0 + cron: + specifier: ^3.1.7 + version: 3.1.7 discord.js: specifier: ^14.15.3 version: 14.15.3(bufferutil@4.0.8)(utf-8-validate@6.0.4) dotenv: specifier: ^16.4.5 version: 16.4.5 + googleapis: + specifier: ^140.0.1 + version: 140.0.1 zlib-sync: specifier: ^0.1.9 version: 0.1.9 @@ -981,6 +987,9 @@ packages: '@types/jest@29.5.12': resolution: {integrity: sha512-eDC8bTvT/QhYdxJAulQikueigY5AsdBRH2yDKW3yveW7svY3+DzN84/2NUgkw10RTiJbWqZrTtoGVdYlvFJdLw==} + '@types/luxon@3.4.2': + resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==} + '@types/node@20.14.6': resolution: {integrity: sha512-JbA0XIJPL1IiNnU7PFxDXyfAwcwVVrOoqyzzyQTyMeVhBzkJVMSkC1LlVsRQ2lpqiY4n6Bb9oCS6lzDKVQxbZw==} @@ -1019,6 +1028,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.1: + resolution: {integrity: sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==} + engines: {node: '>= 14'} + ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} @@ -1107,6 +1120,12 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bignumber.js@9.1.2: + resolution: {integrity: sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -1138,6 +1157,9 @@ packages: bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -1145,6 +1167,10 @@ packages: resolution: {integrity: sha512-4T53u4PdgsXqKaIctwF8ifXlRTTmEPJ8iEPWFdGZvcf7sbwYo6FKFEX9eNNAnzFZ7EzJAQ3CJeOtCRA4rDp7Pw==} engines: {node: '>=6.14.2'} + call-bind@1.0.7: + resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==} + engines: {node: '>= 0.4'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -1246,6 +1272,9 @@ packages: create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + cron@3.1.7: + resolution: {integrity: sha512-tlBg7ARsAMQLzgwqVxy8AZl/qlTc5nibqYwtNGoCrd+cV+ugI+tvZC1oT/8dFH8W455YrywGykx/KMmAqOr7Jw==} + cross-spawn@7.0.3: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} @@ -1280,6 +1309,10 @@ packages: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + detect-newline@3.1.0: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} engines: {node: '>=8'} @@ -1303,6 +1336,9 @@ packages: resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==} engines: {node: '>=12'} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ejs@3.1.10: resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} engines: {node: '>=0.10.0'} @@ -1331,6 +1367,14 @@ packages: error-ex@1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + es-define-property@1.0.0: + resolution: {integrity: sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + escalade@3.1.2: resolution: {integrity: sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==} engines: {node: '>=6'} @@ -1371,6 +1415,9 @@ packages: resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1402,6 +1449,14 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + gaxios@6.7.0: + resolution: {integrity: sha512-DSrkyMTfAnAm4ks9Go20QGOcXEyW/NmZhvTYBU2rb4afBB393WIMQPWPEDMl/k8xqiNN9HYq2zao3oWXsdl2Tg==} + engines: {node: '>=14'} + + gcp-metadata@6.1.0: + resolution: {integrity: sha512-Jh/AIwwgaxan+7ZUUmRLCjtchyDiqh4KjBJ5tW3plBZb5iL/BPcso8A5DlzeD9qlw0duCamnNdpFjxwaT0KyKg==} + engines: {node: '>=14'} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -1414,6 +1469,10 @@ packages: resolution: {integrity: sha512-2nk+7SIVb14QrgXFHcm84tD4bKQz0RxPuMT8Ag5KPOq7J5fEmAg0UbXdTOSHqNuHSU28k55qnceesxXRZGzKWA==} engines: {node: '>=18'} + get-intrinsic@1.2.4: + resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} + engines: {node: '>= 0.4'} + get-package-type@0.1.0: resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} engines: {node: '>=8.0.0'} @@ -1438,9 +1497,28 @@ packages: resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} engines: {node: '>=4'} + google-auth-library@9.13.0: + resolution: {integrity: sha512-p9Y03Uzp/Igcs36zAaB0XTSwZ8Y0/tpYiz5KIde5By+H9DCVUSYtDWZu6aFXsWTqENMb8BD/pDT3hR8NVrPkfA==} + engines: {node: '>=14'} + + googleapis-common@7.2.0: + resolution: {integrity: sha512-/fhDZEJZvOV3X5jmD+fKxMqma5q2Q9nZNSF3kn1F18tpxmA86BcTxAGBQdM0N89Z3bEaIs+HVznSmFJEAmMTjA==} + engines: {node: '>=14.0.0'} + + googleapis@140.0.1: + resolution: {integrity: sha512-ZGvBX4mQcFXO9ACnVNg6Aqy3KtBPB5zTuue43YVLxwn8HSv8jB7w+uDKoIPSoWuxGROgnj2kbng6acXncOQRNA==} + engines: {node: '>=14.0.0'} + + gopd@1.0.1: + resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + gtoken@7.1.0: + resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} + engines: {node: '>=14.0.0'} + has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} @@ -1449,6 +1527,17 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.0.3: + resolution: {integrity: sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==} + engines: {node: '>= 0.4'} + + has-symbols@1.0.3: + resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==} + engines: {node: '>= 0.4'} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -1456,6 +1545,10 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + https-proxy-agent@7.0.5: + resolution: {integrity: sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==} + engines: {node: '>= 14'} + human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -1711,6 +1804,9 @@ packages: engines: {node: '>=4'} hasBin: true + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} @@ -1719,6 +1815,12 @@ packages: engines: {node: '>=6'} hasBin: true + jwa@2.0.0: + resolution: {integrity: sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==} + + jws@4.0.0: + resolution: {integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==} + kleur@3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} @@ -1766,6 +1868,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + luxon@3.4.4: + resolution: {integrity: sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==} + engines: {node: '>=12'} + magic-bytes.js@1.10.0: resolution: {integrity: sha512-/k20Lg2q8LE5xiaaSkMXk4sfvI+9EGEykFS4b0CHHGWqDYU0bGUFSwchNOMA56D7TCs9GwVTkqe9als1/ns8UQ==} @@ -1814,6 +1920,15 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + node-gyp-build@4.8.1: resolution: {integrity: sha512-OSs33Z9yWr148JZcbZd5WiAXhh/n9z8TxQcdMhIOlpN9AhWpLfvVFO73+m77bBABQMaY9XSvIa+qk0jlI7Gcaw==} hasBin: true @@ -1844,6 +1959,10 @@ packages: resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + object-inspect@1.13.2: + resolution: {integrity: sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==} + engines: {node: '>= 0.4'} + obuf@1.1.2: resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} @@ -1969,6 +2088,10 @@ packages: pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + qs@6.13.0: + resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} + engines: {node: '>=0.6'} + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} @@ -2024,6 +2147,9 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -2033,6 +2159,10 @@ packages: engines: {node: '>=10'} hasBin: true + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -2041,6 +2171,10 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + side-channel@1.0.6: + resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==} + engines: {node: '>= 0.4'} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -2156,6 +2290,9 @@ packages: resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} hasBin: true + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + ts-jest@29.2.4: resolution: {integrity: sha512-3d6tgDyhCI29HlpwIq87sNuI+3Q6GLTTCeYRHCs7vDz+/3GCMwEtV9jezLyl4ZtnBgx00I7hm8PCP8cTksMGrw==} engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} @@ -2254,10 +2391,21 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + url-template@2.0.8: + resolution: {integrity: sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==} + utf-8-validate@6.0.4: resolution: {integrity: sha512-xu9GQDeFp+eZ6LnCywXN/zBancWvOpUMzgjLPSjy4BRHSmTelvn2E0DG0o1sTiw5hkCKBHo8rwSKncfRfv2EEQ==} engines: {node: '>=6.14.2'} + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true + v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -2268,6 +2416,12 @@ packages: walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -3579,6 +3733,8 @@ snapshots: expect: 29.7.0 pretty-format: 29.7.0 + '@types/luxon@3.4.2': {} + '@types/node@20.14.6': dependencies: undici-types: 5.26.5 @@ -3618,6 +3774,12 @@ snapshots: acorn@8.11.3: {} + agent-base@7.1.1: + dependencies: + debug: 4.3.6 + transitivePeerDependencies: + - supports-color + ansi-escapes@4.3.2: dependencies: type-fest: 0.21.3 @@ -3733,6 +3895,10 @@ snapshots: balanced-match@1.0.2: {} + base64-js@1.5.1: {} + + bignumber.js@9.1.2: {} + binary-extensions@2.3.0: {} brace-expansion@1.1.11: @@ -3770,12 +3936,22 @@ snapshots: dependencies: node-int64: 0.4.0 + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} bufferutil@4.0.8: dependencies: node-gyp-build: 4.8.1 + call-bind@1.0.7: + dependencies: + es-define-property: 1.0.0 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.2.4 + set-function-length: 1.2.2 + callsites@3.1.0: {} camelcase@5.3.1: {} @@ -3877,6 +4053,11 @@ snapshots: create-require@1.1.1: {} + cron@3.1.7: + dependencies: + '@types/luxon': 3.4.2 + luxon: 3.4.4 + cross-spawn@7.0.3: dependencies: path-key: 3.1.1 @@ -3897,6 +4078,12 @@ snapshots: deepmerge@4.3.1: {} + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.0 + es-errors: 1.3.0 + gopd: 1.0.1 + detect-newline@3.1.0: {} diff-sequences@29.6.3: {} @@ -3925,6 +4112,10 @@ snapshots: dotenv@16.4.5: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ejs@3.1.10: dependencies: jake: 10.9.2 @@ -3945,6 +4136,12 @@ snapshots: dependencies: is-arrayish: 0.2.1 + es-define-property@1.0.0: + dependencies: + get-intrinsic: 1.2.4 + + es-errors@1.3.0: {} + escalade@3.1.2: {} escape-string-regexp@1.0.5: {} @@ -3991,6 +4188,8 @@ snapshots: jest-message-util: 29.7.0 jest-util: 29.7.0 + extend@3.0.2: {} + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} @@ -4019,12 +4218,39 @@ snapshots: function-bind@1.1.2: {} + gaxios@6.7.0: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.5 + is-stream: 2.0.1 + node-fetch: 2.7.0 + uuid: 10.0.0 + transitivePeerDependencies: + - encoding + - supports-color + + gcp-metadata@6.1.0: + dependencies: + gaxios: 6.7.0 + json-bigint: 1.0.0 + transitivePeerDependencies: + - encoding + - supports-color + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} get-east-asian-width@1.2.0: {} + get-intrinsic@1.2.4: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + has-proto: 1.0.3 + has-symbols: 1.0.3 + hasown: 2.0.2 + get-package-type@0.1.0: {} get-stream@6.0.1: {} @@ -4046,18 +4272,77 @@ snapshots: globals@11.12.0: {} + google-auth-library@9.13.0: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 6.7.0 + gcp-metadata: 6.1.0 + gtoken: 7.1.0 + jws: 4.0.0 + transitivePeerDependencies: + - encoding + - supports-color + + googleapis-common@7.2.0: + dependencies: + extend: 3.0.2 + gaxios: 6.7.0 + google-auth-library: 9.13.0 + qs: 6.13.0 + url-template: 2.0.8 + uuid: 9.0.1 + transitivePeerDependencies: + - encoding + - supports-color + + googleapis@140.0.1: + dependencies: + google-auth-library: 9.13.0 + googleapis-common: 7.2.0 + transitivePeerDependencies: + - encoding + - supports-color + + gopd@1.0.1: + dependencies: + get-intrinsic: 1.2.4 + graceful-fs@4.2.11: {} + gtoken@7.1.0: + dependencies: + gaxios: 6.7.0 + jws: 4.0.0 + transitivePeerDependencies: + - encoding + - supports-color + has-flag@3.0.0: {} has-flag@4.0.0: {} + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.0 + + has-proto@1.0.3: {} + + has-symbols@1.0.3: {} + hasown@2.0.2: dependencies: function-bind: 1.1.2 html-escaper@2.0.2: {} + https-proxy-agent@7.0.5: + dependencies: + agent-base: 7.1.1 + debug: 4.3.6 + transitivePeerDependencies: + - supports-color + human-signals@2.1.0: {} human-signals@5.0.0: {} @@ -4513,10 +4798,25 @@ snapshots: jsesc@2.5.2: {} + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.1.2 + json-parse-even-better-errors@2.3.1: {} json5@2.2.3: {} + jwa@2.0.0: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.0: + dependencies: + jwa: 2.0.0 + safe-buffer: 5.2.1 + kleur@3.0.3: {} leven@3.1.0: {} @@ -4573,6 +4873,8 @@ snapshots: dependencies: yallist: 3.1.1 + luxon@3.4.4: {} + magic-bytes.js@1.10.0: {} make-dir@4.0.0: @@ -4612,6 +4914,10 @@ snapshots: natural-compare@1.4.0: {} + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + node-gyp-build@4.8.1: {} node-int64@0.4.0: {} @@ -4643,6 +4949,8 @@ snapshots: dependencies: path-key: 4.0.0 + object-inspect@1.13.2: {} + obuf@1.1.2: {} once@1.4.0: @@ -4747,6 +5055,10 @@ snapshots: pure-rand@6.1.0: {} + qs@6.13.0: + dependencies: + side-channel: 1.0.6 + react-is@18.3.1: {} readdirp@3.6.0: @@ -4801,16 +5113,34 @@ snapshots: rfdc@1.4.1: {} + safe-buffer@5.2.1: {} + semver@6.3.1: {} semver@7.6.2: {} + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.2.4 + gopd: 1.0.1 + has-property-descriptors: 1.0.2 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 shebang-regex@3.0.0: {} + side-channel@1.0.6: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 + object-inspect: 1.13.2 + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -4911,6 +5241,8 @@ snapshots: touch@3.1.1: {} + tr46@0.0.3: {} + ts-jest@29.2.4(@babel/core@7.25.2)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.25.2))(jest@29.7.0(@types/node@22.1.0)(ts-node@10.9.2(@types/node@22.1.0)(typescript@5.5.4)))(typescript@5.5.4): dependencies: bs-logger: 0.2.6 @@ -4989,10 +5321,16 @@ snapshots: escalade: 3.1.2 picocolors: 1.0.1 + url-template@2.0.8: {} + utf-8-validate@6.0.4: dependencies: node-gyp-build: 4.8.1 + uuid@10.0.0: {} + + uuid@9.0.1: {} + v8-compile-cache-lib@3.0.1: {} v8-to-istanbul@9.2.0: @@ -5005,6 +5343,13 @@ snapshots: dependencies: makeerror: 1.0.12 + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + which@2.0.2: dependencies: isexe: 2.0.0 diff --git a/src/calendarService.ts b/src/calendarService.ts new file mode 100644 index 0000000..90e1e41 --- /dev/null +++ b/src/calendarService.ts @@ -0,0 +1,112 @@ +// Google Calendarの操作用 + +import { type calendar_v3, google } from 'googleapis'; +import type { ScheduledEvent } from './types'; + +function createSchemaEvent(event: ScheduledEvent) { + const body: calendar_v3.Schema$Event = { + location: event.location, + id: event.id, + summary: event.name, + description: event.description, + start: { + dateTime: event.starttime.toISOString(), + timeZone: 'Asia/Tokyo', + }, + end: { + // starttimeの1時間後 + dateTime: new Date( + event.starttime.getTime() + 60 * 60 * 1000, + ).toISOString(), + timeZone: 'Asia/Tokyo', + }, + source: { + url: event.url ?? undefined, + title: event.name, + }, + }; + + if (event.recurrence) { + body.recurrence = [event.recurrence]; + } + + return body; +} + +export async function createCalEvent( + access_token: string, + event: ScheduledEvent, +) { + const body: calendar_v3.Schema$Event = createSchemaEvent(event); + const api = google.calendar({ + version: 'v3', + headers: { + Authorization: `Bearer ${access_token}`, + }, + errorRedactor: false, + }); + + try { + await api.events.insert({ + calendarId: 'primary', + requestBody: body, + }); + // biome-ignore lint/suspicious/noExplicitAny: + } catch (ex: any) { + if (ex.status === 409) { + // すでに存在する場合, UIから削除した場合、キャンセル扱いになる + await api.events.update({ + calendarId: 'primary', + eventId: event.id, + requestBody: body, + }); + } + } +} + +export async function updateCalEvent( + access_token: string, + event: ScheduledEvent, +) { + const body: calendar_v3.Schema$Event = createSchemaEvent(event); + const api = google.calendar({ + version: 'v3', + headers: { + Authorization: `Bearer ${access_token}`, + }, + errorRedactor: false, + }); + + await api.events.update({ + calendarId: 'primary', + eventId: event.id, + requestBody: body, + }); +} + +export async function removeCalEvent( + access_token: string, + event: Pick, +) { + const api = google.calendar({ + version: 'v3', + headers: { + Authorization: `Bearer ${access_token}`, + }, + errorRedactor: false, + }); + + try { + await api.events.delete({ + calendarId: 'primary', + eventId: event.id, + }); + // biome-ignore lint/suspicious/noExplicitAny: + } catch (ex: any) { + if (ex.status === 410 || ex.status === 404) { + return; + } + + throw new Error(`Failed to remove event: ${ex}`); + } +} diff --git a/src/dbService.ts b/src/dbService.ts new file mode 100644 index 0000000..e56d6b9 --- /dev/null +++ b/src/dbService.ts @@ -0,0 +1,74 @@ +import { sql } from '@vercel/postgres'; + +export type UserWithGoogleToken = { + id: string; + refresh_token: string; + access_token: string; + id_token: string; + expires_at: number; +}; + +async function retrieveUsersLinkedToCalendar(): Promise { + const result = await sql` + SELECT users.id, accounts.refresh_token, accounts.access_token, accounts.id_token, accounts.expires_at FROM users + JOIN accounts ON accounts."userId" = users.id + WHERE users."isLinkedToCalendar" = true AND accounts.provider = 'google'; + `; + return result.rows; +} + +async function updateUserToken(user: UserWithGoogleToken) { + await sql` + UPDATE accounts SET + access_token = ${user.access_token}, + expires_at = ${user.expires_at} + WHERE "userId" = ${user.id} AND provider = 'google'; + `; +} + +async function refreshAccessToken(refreshToken: string) { + const res = await fetch('https://www.googleapis.com/oauth2/v4/token', { + method: 'POST', + body: new URLSearchParams({ + client_id: process.env.AUTH_GOOGLE_ID ?? '', + client_secret: process.env.AUTH_GOOGLE_SECRET ?? '', + refresh_token: refreshToken, + grant_type: 'refresh_token', + }), + }); + const json = await res.json(); + if ( + res.status !== 200 || + typeof json.access_token !== 'string' || + typeof json.expires_in !== 'number' + ) { + return null; + } + + return { + access_token: json.access_token, + expires_at: Math.floor(Date.now() / 1000) + json.expires_in, + }; +} + +export async function retrieveUsersAndRefresh() { + let users = await retrieveUsersLinkedToCalendar(); + + // 少なくとも60秒後に期限切れなアクセストークンを更新 + for (const user of users.filter( + (user) => user.expires_at < Math.floor(Date.now() / 1000) + 60, + )) { + const json = await refreshAccessToken(user.refresh_token); + if (!json) { + // usersから削除 + users = users.splice(users.indexOf(user), 1); + continue; + } + + user.access_token = json.access_token; + user.expires_at = json.expires_at; + await updateUserToken(user); + } + + return users; +} diff --git a/src/eventSyncUtil.ts b/src/eventSyncUtil.ts new file mode 100644 index 0000000..f7a7a81 --- /dev/null +++ b/src/eventSyncUtil.ts @@ -0,0 +1,62 @@ +import type { ScheduledEvent } from './types'; + +export function getRemovedEvents( + oldEvents: ScheduledEvent[], + newEvents: ScheduledEvent[], +): ScheduledEvent[] { + return oldEvents.filter( + (oldEvent) => + !newEvents.some((newEvent) => newEvent.id === oldEvent.id) && + !(oldEvent.endtime && oldEvent.endtime.getTime() < Date.now()), + ); +} + +export function getUpdatedEvents( + oldEvents: ScheduledEvent[], + newEvents: ScheduledEvent[], +): ScheduledEvent[] { + return newEvents.filter((newEvent) => + oldEvents.some((oldEvent) => { + if (oldEvent.id === newEvent.id) { + if ( + oldEvent.name !== newEvent.name || + oldEvent.description !== newEvent.description || + oldEvent.creatorid !== newEvent.creatorid || + oldEvent.location !== newEvent.location || + oldEvent.recurrence !== newEvent.recurrence + ) { + return true; + } + + if ( + oldEvent.starttime.toTimeString() !== + newEvent.starttime.toTimeString() || + oldEvent.endtime?.toTimeString() !== newEvent.endtime?.toTimeString() + ) { + return true; + } + // 繰り返しのイベントが終了して、日付だけが変更された場合は更新しない + if (newEvent.recurrence) { + if ( + oldEvent.starttime.toDateString() !== + newEvent.starttime.toDateString() || + oldEvent.endtime?.toDateString() !== + newEvent.endtime?.toDateString() + ) { + return false; + } + } + } + return false; + }), + ); +} + +export function getAddedEvents( + oldEvents: ScheduledEvent[], + newEvents: ScheduledEvent[], +): ScheduledEvent[] { + return newEvents.filter( + (newEvent) => !oldEvents.some((oldEvent) => oldEvent.id === newEvent.id), + ); +} diff --git a/src/index.ts b/src/index.ts index 4b823ab..7a56898 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,15 @@ import { sql } from '@vercel/postgres'; -import { ChannelType, Client, type Message, Partials } from 'discord.js'; +import { + type APIGuildScheduledEvent, + ChannelType, + Client, + type GuildScheduledEvent, + GuildScheduledEventStatus, + type Message, + type PartialGuildScheduledEvent, + Partials, + Routes, +} from 'discord.js'; import dotenv from 'dotenv'; import type { @@ -10,6 +20,14 @@ import type { ReactionData, } from './types'; +import { + createCalEvent, + removeCalEvent, + updateCalEvent, +} from './calendarService'; +import { retrieveUsersAndRefresh } from './dbService'; +import { transformAPIGuildScheduledEventToScheduledEvent } from './mapping'; + dotenv.config(); const regexCache = new Map(); @@ -168,8 +186,61 @@ export const handleMessageCreate = } }; +export const handleEventCreate = + (client: Client) => async (event: GuildScheduledEvent) => { + console.log('Event created: ', event.name); + const users = await retrieveUsersAndRefresh(); + const apiObj = (await client.rest.get( + Routes.guildScheduledEvent(event.guildId, event.id), + )) as APIGuildScheduledEvent; + const obj = transformAPIGuildScheduledEventToScheduledEvent(apiObj); + for (const user of users) { + createCalEvent(user.access_token, obj); + } + }; + +export const handleEventUpdate = + (client: Client) => + async ( + oldEvent: GuildScheduledEvent | PartialGuildScheduledEvent | null, + newEvent: GuildScheduledEvent, + ) => { + if ( + newEvent.status === GuildScheduledEventStatus.Completed || + newEvent.status === GuildScheduledEventStatus.Canceled + ) { + handleEventDelete()(newEvent); + } + + console.log('Event updated: ', newEvent.name); + const users = await retrieveUsersAndRefresh(); + // GuildScheduledEventにはrecurrence_ruleがないので、APIから取得する + const apiObj = (await client.rest.get( + Routes.guildScheduledEvent(newEvent.guildId, newEvent.id), + )) as APIGuildScheduledEvent; + const obj = transformAPIGuildScheduledEventToScheduledEvent(apiObj); + for (const user of users) { + updateCalEvent(user.access_token, obj); + } + }; + +export const handleEventDelete = + () => async (event: GuildScheduledEvent | PartialGuildScheduledEvent) => { + console.log('Event deleted: ', event.name); + const users = await retrieveUsersAndRefresh(); + for (const user of users) { + removeCalEvent(user.access_token, event); + } + }; + const client = new Client({ - intents: ['DirectMessages', 'Guilds', 'GuildMessages', 'MessageContent'], + intents: [ + 'DirectMessages', + 'Guilds', + 'GuildMessages', + 'MessageContent', + 'GuildScheduledEvents', + ], partials: [Partials.Channel], }); @@ -179,5 +250,8 @@ client.on( 'messageCreate', handleMessageCreate({ client, regexCache, queryCache, updateQueryCache }), ); +client.on('guildScheduledEventCreate', handleEventCreate(client)); +client.on('guildScheduledEventDelete', handleEventDelete()); +client.on('guildScheduledEventUpdate', handleEventUpdate(client)); client.login(process.env.DISCORD_BOT_TOKEN); diff --git a/src/mapping.ts b/src/mapping.ts new file mode 100644 index 0000000..9accec1 --- /dev/null +++ b/src/mapping.ts @@ -0,0 +1,26 @@ +import type { APIGuildScheduledEvent } from 'discord.js'; +import { convertRFC5545RecurrenceRule } from './recurrenceUtil'; +import type { ScheduledEvent } from './types'; + +// APIGuildScheduledEvent -> ScheduledEvent +export function transformAPIGuildScheduledEventToScheduledEvent( + event: APIGuildScheduledEvent, +): ScheduledEvent { + return { + id: event.id, + name: event.name, + description: event.description ?? null, + starttime: new Date(event.scheduled_start_time), + endtime: event.scheduled_end_time + ? new Date(event.scheduled_end_time) + : null, + creatorid: event.creator_id ?? null, + location: event.entity_metadata?.location ?? null, + // biome-ignore lint/suspicious/noExplicitAny: + recurrence: (event as any).recurrence_rule + ? // biome-ignore lint/suspicious/noExplicitAny: + convertRFC5545RecurrenceRule((event as any).recurrence_rule) + : null, + url: `https://discord.com/events/${event.guild_id}/${event.id}`, + }; +} diff --git a/src/recurrenceUtil.ts b/src/recurrenceUtil.ts new file mode 100644 index 0000000..db3f4df --- /dev/null +++ b/src/recurrenceUtil.ts @@ -0,0 +1,77 @@ +export type APIRecurrenceRuleFrequency = 0 | 1 | 2 | 3; +export type APIRecurrenceRuleWeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6; +export type APIRecurrenceRuleMonth = + | 1 + | 2 + | 3 + | 4 + | 5 + | 6 + | 7 + | 8 + | 9 + | 10 + | 11 + | 12; + +export interface APIRecurrenceRule { + start: Date; + end?: Date | undefined | null; + frequency: APIRecurrenceRuleFrequency; + interval: number; + by_weekday?: APIRecurrenceRuleWeekDay[] | undefined | null; + by_n_weekday?: + | { n: 1 | 2 | 3 | 4 | 5; day: APIRecurrenceRuleWeekDay }[] + | undefined + | null; + by_month?: APIRecurrenceRuleMonth[] | undefined | null; + by_month_day?: number[] | undefined | null; + by_year_day?: number[] | undefined | null; + count?: number | undefined | null; +} + +export function getWeekdayString(weekday: APIRecurrenceRuleWeekDay): string { + // https://discord.com/developers/docs/resources/guild-scheduled-event#guild-scheduled-event-recurrence-rule-object-guild-scheduled-event-recurrence-rule-weekday + // 月曜始まり + return ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'][weekday]; +} + +export function getFrequencyString( + frequency: APIRecurrenceRuleFrequency, +): string { + return ['YEARLY', 'MONTHLY', 'WEEKLY', 'DAILY'][frequency]; +} + +// https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.10 +export function convertRFC5545RecurrenceRule( + rule: APIRecurrenceRule, +): string | undefined { + const { by_weekday, by_n_weekday, by_month, by_month_day, by_year_day } = + rule; + let str = `RRULE:FREQ=${getFrequencyString(rule.frequency)};INTERVAL=${rule.interval}`; + + if (by_weekday) { + str += `;BYDAY=${by_weekday.map((day) => getWeekdayString(day)).join(',')}`; + } + if (by_n_weekday) { + str += `;BYDAY=${by_n_weekday.map((day) => `${day.n}${getWeekdayString(day.day)}`).join(',')}`; + } + if (by_month) { + str += `;BYMONTH=${by_month.join(',')}`; + } + if (by_month_day) { + str += `;BYMONTHDAY=${by_month_day.join(',')}`; + } + if (by_year_day) { + str += `;BYYEARDAY=${by_year_day.join(',')}`; + } + + if (rule.end) { + str += `;UNTIL=${rule.end.toISOString().replace(/[-:]/g, '').split('.')[0]}Z`; + } + if (rule.count) { + str += `;COUNT=${rule.count}`; + } + + return str; +} diff --git a/src/types/index.ts b/src/types/index.ts index 54c03a3..8b0912e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -20,3 +20,15 @@ export interface QueryCache { reactionAgentEmojis: ReactionAgentEmoji[]; commands: Command[]; } + +export interface ScheduledEvent { + id: string; + name: string; + description?: string | undefined | null; + starttime: Date; + endtime?: Date | undefined | null; + creatorid?: string | undefined | null; + location?: string | undefined | null; + recurrence?: string | undefined | null; + url?: string | undefined | null; +} diff --git a/test/index.test.ts b/test/index.test.ts index ff66ce4..d70ff04 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,11 +1,34 @@ -import { ChannelType, type Client, type Message } from 'discord.js'; +import { + type APIGuildScheduledEvent, + ChannelType, + type Client, + type GuildScheduledEvent, + GuildScheduledEventStatus, + type Message, +} from 'discord.js'; +import { + createCalEvent, + removeCalEvent, + updateCalEvent, +} from '../src/calendarService'; +import { retrieveUsersAndRefresh } from '../src/dbService'; import { handleClientReady, + handleEventCreate, + handleEventDelete, + handleEventUpdate, handleMessageCreate, updateQueryCache, } from '../src/index'; -import type { QueryCache } from '../src/types'; +import { transformAPIGuildScheduledEventToScheduledEvent } from '../src/mapping'; +import { + type APIRecurrenceRule, + convertRFC5545RecurrenceRule, + getFrequencyString, + getWeekdayString, +} from '../src/recurrenceUtil'; +import type { QueryCache, ScheduledEvent } from '../src/types'; jest.mock('discord.js', () => { const originalModule = jest.requireActual('discord.js'); @@ -20,6 +43,15 @@ jest.mock('discord.js', () => { }; }); +jest.mock('../src/dbService', () => ({ + retrieveUsersAndRefresh: jest.fn(), +})); +jest.mock('../src/calendarService', () => ({ + createCalEvent: jest.fn(), + updateCalEvent: jest.fn(), + removeCalEvent: jest.fn(), +})); + const expectReactionsToHaveBeenCalled = (mockReact: jest.Mock) => { expect(mockReact).toHaveBeenCalledWith('1223834970863177769'); expect(mockReact).toHaveBeenCalledWith('🔥'); @@ -296,3 +328,361 @@ describe('handleMessageCreate', () => { expect(mockDelete).not.toHaveBeenCalled(); }); }); + +describe('Event Handlers', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const createMockEvent = ({ + guildId, + id, + name, + description, + creatorId, + startTime, + endTime, + location, + status = GuildScheduledEventStatus.Scheduled, + }: { + guildId: string; + id: string; + name: string; + description: string; + creatorId: string; + startTime: string; + endTime: string; + location: string; + status?: GuildScheduledEventStatus; + }) => { + const event = { + guildId, + id, + name, + status, + } as GuildScheduledEvent; + const apiObj = { + id, + guild_id: guildId, + name, + description, + creator_id: creatorId, + scheduled_start_time: startTime, + scheduled_end_time: endTime, + entity_metadata: { + location, + }, + status: status ?? GuildScheduledEventStatus.Scheduled, + } as APIGuildScheduledEvent; + const transformedObj = { + id, + name, + description, + starttime: new Date(startTime), + endtime: new Date(endTime), + creatorid: creatorId, + location, + recurrence: null, + url: `https://discord.com/events/${guildId}/${id}`, + }; + return { event, apiObj, transformedObj }; + }; + + test('handleEventCreate should call createCalEvent for each user', async () => { + const mockUsers = [{ access_token: 'token1' }, { access_token: 'token2' }]; + const { + event: mockEvent, + apiObj: mockApiObj, + transformedObj: mockTransformedObj, + } = createMockEvent({ + guildId: '123', + id: '456', + name: 'Test Event', + description: 'Test Description', + creatorId: '789', + startTime: '2022-01-01T00:00:00Z', + endTime: '2022-01-01T01:00:00Z', + location: 'Test Location', + }); + const mockClient = { rest: { get: jest.fn() } } as unknown as Client; + + (retrieveUsersAndRefresh as jest.Mock).mockResolvedValue(mockUsers); + (mockClient.rest.get as jest.Mock).mockResolvedValue(mockApiObj); + + await handleEventCreate(mockClient)(mockEvent); + + expect(retrieveUsersAndRefresh).toHaveBeenCalled(); + expect(createCalEvent).toHaveBeenCalledTimes(mockUsers.length); + for (const user of mockUsers) { + expect(createCalEvent).toHaveBeenCalledWith( + user.access_token, + mockTransformedObj, + ); + } + }); + + test('handleEventUpdate should call updateCalEvent for each user', async () => { + const mockUsers = [{ access_token: 'token1' }, { access_token: 'token2' }]; + const { + event: mockEvent, + apiObj: mockApiObj, + transformedObj: mockTransformedObj, + } = createMockEvent({ + guildId: '123', + id: '456', + name: 'Test Event', + description: 'Test Description', + creatorId: '789', + startTime: '2022-01-01T00:00:00Z', + endTime: '2022-01-01T01:00:00Z', + location: 'Test Location', + }); + const mockClient = { rest: { get: jest.fn() } } as unknown as Client; + + (retrieveUsersAndRefresh as jest.Mock).mockResolvedValue(mockUsers); + (mockClient.rest.get as jest.Mock).mockResolvedValue(mockApiObj); + + await handleEventUpdate(mockClient)({} as GuildScheduledEvent, mockEvent); + + expect(retrieveUsersAndRefresh).toHaveBeenCalled(); + expect(updateCalEvent).toHaveBeenCalledTimes(mockUsers.length); + for (const user of mockUsers) { + expect(updateCalEvent).toHaveBeenCalledWith( + user.access_token, + mockTransformedObj, + ); + } + }); + + test('handleEventUpdate should call handleEventDelete if the event is completed or canceled', async () => { + const mockUsers = [{ access_token: 'token1' }, { access_token: 'token2' }]; + const { event: mockEvent, apiObj: mockApiObj } = createMockEvent({ + guildId: '123', + id: '456', + name: 'Test Event', + description: 'Test Description', + creatorId: '789', + startTime: '2022-01-01T00:00:00Z', + endTime: '2022-01-01T01:00:00Z', + location: 'Test Location', + status: GuildScheduledEventStatus.Completed, + }); + const mockClient = { rest: { get: jest.fn() } } as unknown as Client; + + (retrieveUsersAndRefresh as jest.Mock).mockResolvedValue(mockUsers); + (mockClient.rest.get as jest.Mock).mockResolvedValue(mockApiObj); + + await handleEventUpdate(mockClient)({} as GuildScheduledEvent, mockEvent); + + expect(retrieveUsersAndRefresh).toHaveBeenCalled(); + expect(removeCalEvent).toHaveBeenCalledTimes(mockUsers.length); + for (const user of mockUsers) { + expect(removeCalEvent).toHaveBeenCalledWith(user.access_token, mockEvent); + } + }); + + test('handleEventDelete should call removeCalEvent for each user', async () => { + const mockEvent = { + id: '456', + name: 'Deleted Event', + } as GuildScheduledEvent; + const mockUsers = [{ access_token: 'token1' }, { access_token: 'token2' }]; + + (retrieveUsersAndRefresh as jest.Mock).mockResolvedValue(mockUsers); + + await handleEventDelete()(mockEvent); + + expect(retrieveUsersAndRefresh).toHaveBeenCalled(); + expect(removeCalEvent).toHaveBeenCalledTimes(mockUsers.length); + for (const user of mockUsers) { + expect(removeCalEvent).toHaveBeenCalledWith(user.access_token, mockEvent); + } + }); +}); + +describe('recurrenceUtil', () => { + describe('getWeekdayString', () => { + it('should return correct weekday string for each input', () => { + expect(getWeekdayString(0)).toBe('MO'); + expect(getWeekdayString(1)).toBe('TU'); + expect(getWeekdayString(2)).toBe('WE'); + expect(getWeekdayString(3)).toBe('TH'); + expect(getWeekdayString(4)).toBe('FR'); + expect(getWeekdayString(5)).toBe('SA'); + expect(getWeekdayString(6)).toBe('SU'); + }); + }); + + describe('getFrequencyString', () => { + it('should return correct frequency string for each input', () => { + expect(getFrequencyString(0)).toBe('YEARLY'); + expect(getFrequencyString(1)).toBe('MONTHLY'); + expect(getFrequencyString(2)).toBe('WEEKLY'); + expect(getFrequencyString(3)).toBe('DAILY'); + }); + }); + + describe('convertRFC5545RecurrenceRule', () => { + it('should return correct RFC5545 string for minimal valid input', () => { + const rule: APIRecurrenceRule = { + start: new Date(), + frequency: 2, + interval: 1, + }; + expect(convertRFC5545RecurrenceRule(rule)).toBe( + 'RRULE:FREQ=WEEKLY;INTERVAL=1', + ); + }); + + it('should return correct RFC5545 string with all optional fields', () => { + const rule: APIRecurrenceRule = { + start: new Date(), + frequency: 2, + interval: 1, + by_weekday: [0, 2], + by_n_weekday: [{ n: 1, day: 0 }], + by_month: [1, 3], + by_month_day: [1, 15], + by_year_day: [1, 100], + }; + expect(convertRFC5545RecurrenceRule(rule)).toBe( + 'RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,WE;BYDAY=1MO;BYMONTH=1,3;BYMONTHDAY=1,15;BYYEARDAY=1,100', + ); + }); + + it('should return correct RFC5545 string with end date', () => { + const rule: APIRecurrenceRule = { + start: new Date(), + frequency: 2, + interval: 1, + end: new Date('2023-12-31T23:59:59Z'), + }; + expect(convertRFC5545RecurrenceRule(rule)).toBe( + 'RRULE:FREQ=WEEKLY;INTERVAL=1;UNTIL=20231231T235959Z', + ); + }); + + it('should return correct RFC5545 string with count', () => { + const rule: APIRecurrenceRule = { + start: new Date(), + frequency: 2, + interval: 1, + count: 10, + }; + expect(convertRFC5545RecurrenceRule(rule)).toBe( + 'RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=10', + ); + }); + + it('should return correct RFC5545 string with combinations of optional fields', () => { + const rule: APIRecurrenceRule = { + start: new Date(), + frequency: 2, + interval: 1, + by_weekday: [0, 2], + by_month: [1, 3], + count: 10, + }; + expect(convertRFC5545RecurrenceRule(rule)).toBe( + 'RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,WE;BYMONTH=1,3;COUNT=10', + ); + }); + }); +}); + +describe('transformAPIGuildScheduledEventToScheduledEvent', () => { + it('should transform APIGuildScheduledEvent to ScheduledEvent with all fields', () => { + const apiEvent: APIGuildScheduledEvent = { + id: '123', + name: 'Test Event', + description: 'This is a test event', + scheduled_start_time: '2023-10-01T10:00:00Z', + scheduled_end_time: '2023-10-01T12:00:00Z', + creator_id: '456', + entity_metadata: { location: 'Test Location' }, + guild_id: '789', + recurrence_rule: { + start: new Date(), + frequency: 3, + interval: 1, + } as APIRecurrenceRule, + // biome-ignore lint/suspicious/noExplicitAny: + } as any; + + const result: ScheduledEvent = + transformAPIGuildScheduledEventToScheduledEvent(apiEvent); + + expect(result).toEqual({ + id: '123', + name: 'Test Event', + description: 'This is a test event', + starttime: new Date('2023-10-01T10:00:00Z'), + endtime: new Date('2023-10-01T12:00:00Z'), + creatorid: '456', + location: 'Test Location', + recurrence: 'RRULE:FREQ=DAILY;INTERVAL=1', + url: 'https://discord.com/events/789/123', + }); + }); + + it('should transform APIGuildScheduledEvent to ScheduledEvent with missing optional fields', () => { + const apiEvent: APIGuildScheduledEvent = { + id: '123', + name: 'Test Event', + scheduled_start_time: '2023-10-01T10:00:00Z', + guild_id: '789', + // biome-ignore lint/suspicious/noExplicitAny: + } as any; + + const result: ScheduledEvent = + transformAPIGuildScheduledEventToScheduledEvent(apiEvent); + + expect(result).toEqual({ + id: '123', + name: 'Test Event', + description: null, + starttime: new Date('2023-10-01T10:00:00Z'), + endtime: null, + creatorid: null, + location: null, + recurrence: null, + url: 'https://discord.com/events/789/123', + }); + }); + + it('should transform APIGuildScheduledEvent to ScheduledEvent with recurrence rule', () => { + const apiEvent: APIGuildScheduledEvent = { + id: '123', + name: 'Test Event', + scheduled_start_time: '2023-10-01T10:00:00Z', + guild_id: '789', + recurrence_rule: { + start: new Date(), + frequency: 2, + interval: 1, + by_weekday: [0], + } as APIRecurrenceRule, + // biome-ignore lint/suspicious/noExplicitAny: + } as any; + + const result: ScheduledEvent = + transformAPIGuildScheduledEventToScheduledEvent(apiEvent); + + expect(result.recurrence).toBe('RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=MO'); + }); + + it('should transform APIGuildScheduledEvent to ScheduledEvent without recurrence rule', () => { + const apiEvent: APIGuildScheduledEvent = { + id: '123', + name: 'Test Event', + scheduled_start_time: '2023-10-01T10:00:00Z', + guild_id: '789', + // biome-ignore lint/suspicious/noExplicitAny: + } as any; + + const result: ScheduledEvent = + transformAPIGuildScheduledEventToScheduledEvent(apiEvent); + + expect(result.recurrence).toBe(null); + }); +});