Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e476e23d37 | ||
|
|
a4b3d40efa | ||
| 26393fec2f | |||
| b670a61192 | |||
| e5f3b87b1d | |||
| d0dcdb1563 |
6
bot/db/db.d.ts
vendored
6
bot/db/db.d.ts
vendored
@@ -8,9 +8,3 @@ export interface GuildType {
|
||||
};
|
||||
}
|
||||
export type GuildRow = Omit<GuildType, "options"> & { options: string };
|
||||
|
||||
// export interface UserType {
|
||||
// guild_id: string;
|
||||
// id: string;
|
||||
// name: string;
|
||||
// }
|
||||
317
bot/package-lock.json
generated
317
bot/package-lock.json
generated
@@ -9,7 +9,6 @@
|
||||
"version": "0.0.1",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"axios": "^1.14.0",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"colors": "^1.4.0",
|
||||
"discord.js": "^14.25.1",
|
||||
@@ -40,9 +39,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@discordjs/builders": {
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.0.tgz",
|
||||
"integrity": "sha512-7pVKxVWkeLUtrTo9nTYkjRcJk0Hlms6lYervXAD7E7+K5lil9ms2JrEB1TalMiHvQMh7h1HJZ4fCJa0/vHpl4w==",
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz",
|
||||
"integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@discordjs/formatters": "^0.6.2",
|
||||
@@ -361,23 +360,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"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/axios": {
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
|
||||
"integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@@ -456,19 +438,6 @@
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"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/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
@@ -493,18 +462,6 @@
|
||||
"node": ">=0.1.90"
|
||||
}
|
||||
},
|
||||
"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/create-require": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
||||
@@ -553,15 +510,6 @@
|
||||
"node": ">=4.0.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/denque": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||
@@ -600,24 +548,24 @@
|
||||
]
|
||||
},
|
||||
"node_modules/discord.js": {
|
||||
"version": "14.25.1",
|
||||
"resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz",
|
||||
"integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==",
|
||||
"version": "14.26.3",
|
||||
"resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.3.tgz",
|
||||
"integrity": "sha512-XEKtYn28YFsiJ5l4fLRyikdbo6RD5oFyqfVHQlvXz2104JhH/E8slN28dbky05w3DCrJcNVWvhVvcJCTSl/KIg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@discordjs/builders": "^1.13.0",
|
||||
"@discordjs/builders": "^1.14.1",
|
||||
"@discordjs/collection": "1.5.3",
|
||||
"@discordjs/formatters": "^0.6.2",
|
||||
"@discordjs/rest": "^2.6.0",
|
||||
"@discordjs/rest": "^2.6.1",
|
||||
"@discordjs/util": "^1.2.0",
|
||||
"@discordjs/ws": "^1.2.3",
|
||||
"@sapphire/snowflake": "3.5.3",
|
||||
"discord-api-types": "^0.38.33",
|
||||
"discord-api-types": "^0.38.40",
|
||||
"fast-deep-equal": "3.1.3",
|
||||
"lodash.snakecase": "4.1.1",
|
||||
"magic-bytes.js": "^1.10.0",
|
||||
"magic-bytes.js": "^1.13.0",
|
||||
"tslib": "^2.6.3",
|
||||
"undici": "6.21.3"
|
||||
"undici": "6.24.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -627,9 +575,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/discord.js/node_modules/undici": {
|
||||
"version": "6.21.3",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz",
|
||||
"integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==",
|
||||
"version": "6.24.1",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz",
|
||||
"integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
@@ -647,20 +595,6 @@
|
||||
"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.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
@@ -670,51 +604,6 @@
|
||||
"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/expand-template": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||
@@ -736,151 +625,18 @@
|
||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"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",
|
||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"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/github-from-package": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
|
||||
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"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/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/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
@@ -938,9 +694,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.defaults": {
|
||||
@@ -974,36 +730,6 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"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/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",
|
||||
@@ -1091,15 +817,6 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
"typescript": "^6.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.14.0",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"colors": "^1.4.0",
|
||||
"discord.js": "^14.25.1",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Client, ClientEvents, ColorResolvable, EmbedBuilder, EmbedField, GatewayIntentBits, Message } from "discord.js";
|
||||
import { Config } from "../utils/Config";
|
||||
import { Logger } from "../utils/Logger";
|
||||
|
||||
export class BotClient extends Client {
|
||||
public prefix = Config.prefix;
|
||||
@@ -69,8 +70,10 @@ export class BotClient extends Client {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const msg = await message.fetch(true).catch(() => undefined);
|
||||
if (msg?.deletable) msg.delete().catch(() => {});
|
||||
} catch {};
|
||||
if (msg?.deletable) msg.delete().catch((err) => {
|
||||
Logger.warn(`[BotClient] 메세지 삭제 실패: ${String(err)}`);
|
||||
});
|
||||
} catch {}
|
||||
}, Math.max(100, time * (customTime ? 1 : 6000)));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Guild, Message, TextChannel } from "discord.js";
|
||||
import { LoadType, Player, Track, TrackEndEvent, TrackStartEvent } from "shoukaku";
|
||||
import { LoadType, Player, Track, TrackEndEvent, TrackStartEvent, type WebSocketClosedEvent } from "shoukaku";
|
||||
import { client, lavalinkManager, Redis } from "../index";
|
||||
import { timeFormat } from "../utils/music/Utils";
|
||||
import { default_content, default_embed, default_image, getButtons } from "../utils/music/Config";
|
||||
@@ -22,6 +22,7 @@ export class GuildPlayer {
|
||||
public queue: QueueTrack[] = [];
|
||||
private errorTimer: NodeJS.Timeout | undefined;
|
||||
private endTimer: NodeJS.Timeout | undefined;
|
||||
private closedTimer: NodeJS.Timeout | undefined;
|
||||
|
||||
constructor(
|
||||
public guild: Guild,
|
||||
@@ -31,12 +32,22 @@ export class GuildPlayer {
|
||||
public msg: Message,
|
||||
) {
|
||||
this.player.setGlobalVolume(50);
|
||||
this.attachPlayerListeners();
|
||||
}
|
||||
|
||||
private attachPlayerListeners() {
|
||||
this.player.on("start", (_data: TrackStartEvent) => {
|
||||
// endTimer가 남아있으면 제거 (새 곡 재생 시작)
|
||||
if (this.endTimer !== undefined) {
|
||||
clearTimeout(this.endTimer);
|
||||
this.endTimer = undefined;
|
||||
}
|
||||
Redis?.publishState("player_update", {
|
||||
guildId: this.guild.id,
|
||||
});
|
||||
});
|
||||
this.player.on("end", async (data: TrackEndEvent) => {
|
||||
try {
|
||||
if (this.isDead) return;
|
||||
if (data.reason === "replaced") return;
|
||||
// 방금 끝난 곡을 대기열에서 지우면서 lastPlayedTrack에 저장
|
||||
@@ -48,46 +59,114 @@ export class GuildPlayer {
|
||||
} else {
|
||||
this.end();
|
||||
}
|
||||
} catch (err) {
|
||||
Logger.error(`[GuildPlayer] end 이벤트 처리 중 에러: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
this.player.on("closed", () => {
|
||||
this.player.on("closed", (data: WebSocketClosedEvent) => {
|
||||
if (this.isDead) return;
|
||||
Logger.info(`[GuildPlayer] 음성 연결이 끊어졌습니다. 재접속을 대기합니다...`);
|
||||
setTimeout(() => {
|
||||
Logger.warn(
|
||||
`[GuildPlayer] 음성 WS 끊김 (code=${data.code}, reason="${data.reason || "(none)"}", byRemote=${data.byRemote}). 5초 후 복구 시도...`,
|
||||
);
|
||||
// 이전 closed 타이머가 있으면 제거
|
||||
if (this.closedTimer !== undefined) {
|
||||
clearTimeout(this.closedTimer);
|
||||
}
|
||||
this.closedTimer = setTimeout(async () => {
|
||||
this.closedTimer = undefined;
|
||||
if (this.isDead) return;
|
||||
// 5초가 지났는데도 연결이 복구되지 않았을 때만 방을 나갑니다.
|
||||
|
||||
// 디스코드 방에 내 봇(me)이 없으면 봇을 삭제(delete)한다!
|
||||
if (!this.guild.members.me?.voice?.channelId) {
|
||||
const meChannelId = this.guild.members.me?.voice?.channelId;
|
||||
if (!meChannelId) {
|
||||
Logger.warn(`[GuildPlayer] 음성채널에 봇이 없습니다. player를 초기화합니다.`);
|
||||
return this.delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* declare enum State {
|
||||
* CONNECTING = 0,
|
||||
* CONNECTED = 1,
|
||||
* DISCONNECTING = 2,
|
||||
* DISCONNECTED = 3
|
||||
* }
|
||||
*/
|
||||
// (1 = CONNECTED, Shoukaku 버전에 따라 연결 상태 체크가 다를 수 있으니 안전하게 확인)
|
||||
if (this.player && this.player.node.state !== 1) {
|
||||
Logger.warn(`[GuildPlayer] 연결 복구 실패. 봇을 퇴장시킵니다.`);
|
||||
return this.delete();
|
||||
// 봇이 아직 보이스 채널에 남아있으면 자가 회복 시도
|
||||
try {
|
||||
await this.reconnect(meChannelId);
|
||||
} catch (err) {
|
||||
Logger.error(`[GuildPlayer] 음성 재접속 실패: ${String(err)}. player를 초기화합니다.`);
|
||||
this.delete();
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
this.player.on("exception", async (data) => {
|
||||
try {
|
||||
Logger.error(`[Lavalink] 재생 중 에러 발생: ${data.exception?.message}`);
|
||||
await this.errMsg("유튜브 차단 또는 재생 오류로 인해 이 곡을 건너뜁니다.");
|
||||
} catch (err) {
|
||||
Logger.error(`[GuildPlayer] exception 이벤트 처리 중 에러: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
this.player.on("stuck", async (data) => {
|
||||
try {
|
||||
Logger.error(`[Lavalink] 곡 로딩 멈춤(Stuck) 발생: ${data.thresholdMs}ms 초과`);
|
||||
await this.errMsg("음원 로딩이 멈췄습니다. 다음 곡으로 넘어갑니다.");
|
||||
} catch (err) {
|
||||
Logger.error(`[GuildPlayer] stuck 이벤트 처리 중 에러: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 음성 WS가 끊겼지만 봇이 아직 보이스 채널에 남아있을 때, player를 새로 만들어 현재 곡을 이어서 재생. */
|
||||
private async reconnect(targetChannelId: string) {
|
||||
const currentTrack = this.nowTrack;
|
||||
const currentPosition = this.position;
|
||||
const wasPaused = this.isPaused;
|
||||
const savedVolume = this.player.volume;
|
||||
|
||||
Logger.info(
|
||||
`[GuildPlayer] 음성 재접속 시도 (channel=${targetChannelId}, track="${currentTrack?.info.title ?? "(none)"}", position=${currentPosition}ms, paused=${wasPaused})`,
|
||||
);
|
||||
|
||||
// 기존 player listener 해제 + 정리
|
||||
this.player.removeAllListeners();
|
||||
try {
|
||||
await this.player.destroy();
|
||||
} catch (err) {
|
||||
Logger.warn(`[GuildPlayer] 기존 player.destroy 중 에러(무시): ${String(err)}`);
|
||||
}
|
||||
try {
|
||||
await lavalinkManager.shoukaku.leaveVoiceChannel(this.guild.id);
|
||||
} catch (err) {
|
||||
Logger.warn(`[GuildPlayer] 기존 leaveVoiceChannel 중 에러(무시): ${String(err)}`);
|
||||
}
|
||||
|
||||
if (this.isDead) return;
|
||||
|
||||
// 게이트웨이 측 정리 대기
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
if (this.isDead) return;
|
||||
|
||||
// 재접속 (봇이 옮겨졌을 수도 있으니 me의 현재 채널로)
|
||||
const newPlayer = await lavalinkManager.shoukaku.joinVoiceChannel({
|
||||
guildId: this.guild.id,
|
||||
channelId: targetChannelId,
|
||||
shardId: this.guild.shardId,
|
||||
deaf: true,
|
||||
mute: false,
|
||||
});
|
||||
this.player = newPlayer;
|
||||
this.voiceChannelId = targetChannelId;
|
||||
this.attachPlayerListeners();
|
||||
await this.player.setGlobalVolume(savedVolume || 50);
|
||||
|
||||
// 곡이 있었으면 이어서 재생
|
||||
if (currentTrack) {
|
||||
await this.player.playTrack({
|
||||
track: { encoded: currentTrack.encoded },
|
||||
position: currentPosition,
|
||||
paused: wasPaused,
|
||||
});
|
||||
Logger.info(`[GuildPlayer] 음성 재접속 성공. 곡을 이어서 재생합니다.`);
|
||||
} else {
|
||||
Logger.info(`[GuildPlayer] 음성 재접속 성공 (재생 중인 곡 없음).`);
|
||||
}
|
||||
}
|
||||
|
||||
private get GDB() {
|
||||
if (!this._GDB) this._GDB = DB.guild.get(this.guild.id);
|
||||
return this._GDB;
|
||||
@@ -214,16 +293,31 @@ export class GuildPlayer {
|
||||
this.end();
|
||||
return;
|
||||
}
|
||||
if (tracks[0].info.identifier === trackId) tracks = tracks.slice(1);
|
||||
if (tracks.length > 0 && tracks[0].info.identifier === trackId) tracks = tracks.slice(1);
|
||||
if (tracks.length === 0) {
|
||||
this.end();
|
||||
return;
|
||||
}
|
||||
this.addTracks(tracks, "자동재생");
|
||||
}
|
||||
|
||||
public end() {
|
||||
private clearAllTimers() {
|
||||
if (this.errorTimer !== undefined) {
|
||||
clearTimeout(this.errorTimer);
|
||||
this.errorTimer = undefined;
|
||||
}
|
||||
if (this.endTimer !== undefined) clearTimeout(this.endTimer);
|
||||
if (this.endTimer !== undefined) {
|
||||
clearTimeout(this.endTimer);
|
||||
this.endTimer = undefined;
|
||||
}
|
||||
if (this.closedTimer !== undefined) {
|
||||
clearTimeout(this.closedTimer);
|
||||
this.closedTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
public end() {
|
||||
this.clearAllTimers();
|
||||
this.endTimer = setTimeout(() => {
|
||||
this.endTimer = undefined;
|
||||
this.delete(true);
|
||||
@@ -238,7 +332,10 @@ export class GuildPlayer {
|
||||
if (this.isDead) return;
|
||||
if (!afterEnd) this.end();
|
||||
this.isDead = true;
|
||||
this.player.destroy().catch(() => {});
|
||||
this.clearAllTimers();
|
||||
this.player.destroy().catch((err) => {
|
||||
Logger.error(`[GuildPlayer] player.destroy 에러: ${String(err)}`);
|
||||
});
|
||||
lavalinkManager.delPlayer(this.guild.id);
|
||||
lavalinkManager.shoukaku.leaveVoiceChannel(this.guild.id);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ChatInputCommandInteraction, Collection } from "discord.js";
|
||||
import { readdirSync } from "node:fs";
|
||||
import { Command } from "../types/Command";
|
||||
import { COMMAND_PATH, COMMANDS_PATH } from "../utils/Config";
|
||||
import { Logger } from "../utils/Logger";
|
||||
|
||||
export class Handler {
|
||||
public commands: Collection<string, Command> = new Collection();
|
||||
@@ -15,11 +16,16 @@ export class Handler {
|
||||
}
|
||||
}
|
||||
|
||||
public runCommand(interaction: ChatInputCommandInteraction) {
|
||||
public async runCommand(interaction: ChatInputCommandInteraction) {
|
||||
const commandName = interaction.commandName;
|
||||
const command = this.commands.get(commandName);
|
||||
|
||||
if (!command) return;
|
||||
if (command.slashRun) command.slashRun(interaction);
|
||||
try {
|
||||
if (command.slashRun) await command.slashRun(interaction);
|
||||
} catch (err) {
|
||||
Logger.error(`[Handler] 명령어 '${commandName}' 실행 중 에러: ${String(err)}`);
|
||||
await interaction.editReply({ content: "명령어 실행 중 오류가 발생했습니다." }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,14 +142,17 @@ class RedisClientClass {
|
||||
const resultKey = `queue:remove:${data.requestId}`;
|
||||
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||
if (!data.index) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index를 찾을수 없습니다." }));
|
||||
// index 는 number(0 도 유효) — typeof 검증으로 변경.
|
||||
if (typeof data.index !== "number") return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index는 number 이어야 합니다." }));
|
||||
const context = await this.getContext(data.serverId, resultKey, data.userId);
|
||||
if (!context.ok) return;
|
||||
const numIndex = Number(data.index);
|
||||
if (isNaN(numIndex)) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index 타입이 올바르지 않습니다." }));
|
||||
if (numIndex < 0) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index는 0보다 크거나 같아야합니다." }));
|
||||
if (numIndex >= context.player.queue.length-1) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index가 queue.length보다 클수 없습니다." }));
|
||||
const [removedTrack] = context.player.queue.splice(numIndex+1, 1);
|
||||
// queue[0]은 현재 재생중인 곡이므로 실제 대기열은 queue[1]부터 시작
|
||||
// numIndex는 대기열(queue[1]~) 기준이므로 실제 splice 위치<EC9C84><ECB998> numIndex+1
|
||||
if (numIndex >= context.player.queue.length - 1) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index가 대기열 범위를 초과합니다." }));
|
||||
const [removedTrack] = context.player.queue.splice(numIndex + 1, 1);
|
||||
await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, removedTrack }));
|
||||
context.player.setMsg();
|
||||
}
|
||||
@@ -157,7 +160,8 @@ class RedisClientClass {
|
||||
const resultKey = `player:paused:${data.requestId}`;
|
||||
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||
if (!data.isPaused) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "paused를 찾을수 없습니다." }));
|
||||
// isPaused 는 boolean — false 도 정상 입력. typeof 검증으로 변경.
|
||||
if (typeof data.isPaused !== "boolean") return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "isPaused는 boolean 이어야 합니다." }));
|
||||
const context = await this.getContext(data.serverId, resultKey, data.userId);
|
||||
if (!context.ok) return;
|
||||
await context.player.setPause();
|
||||
@@ -176,11 +180,12 @@ class RedisClientClass {
|
||||
const resultKey = `player:seek:${data.requestId}`;
|
||||
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||
if (!data.seek) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "seek를 찾을수 없습니다." }));
|
||||
// seek 는 number(0 도 유효 — 처음으로 되감기) — typeof 검증으로 변경.
|
||||
if (typeof data.seek !== "number") return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "seek는 number 이어야 합니다." }));
|
||||
const context = await this.getContext(data.serverId, resultKey, data.userId);
|
||||
if (!context.ok) return;
|
||||
if (!context.player.isPlaying || !context.player.nowTrack) await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." }));
|
||||
const duration = context.player.nowTrack?.info.length || 0;
|
||||
if (!context.player.isPlaying || !context.player.nowTrack) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." }));
|
||||
const duration = context.player.nowTrack.info.length || 0;
|
||||
const numSeek = Number(data.seek);
|
||||
if (isNaN(numSeek)) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "seek 타입이 올바르지 않습니다." }));
|
||||
if (numSeek < 0) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "seek는 0보다 크거나 같아야합니다." }));
|
||||
@@ -192,10 +197,11 @@ class RedisClientClass {
|
||||
const resultKey = `player:volume:${data.requestId}`;
|
||||
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||
if (!data.volume) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume을 찾을수 없습니다." }));
|
||||
// volume 은 number(0 도 유효 — 음소거) — typeof 검증으로 변경.
|
||||
if (typeof data.volume !== "number") return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume은 number 이어야 합니다." }));
|
||||
const context = await this.getContext(data.serverId, resultKey, data.userId);
|
||||
if (!context.ok) return;
|
||||
if (!context.player.isPlaying || !context.player.nowTrack) await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." }));
|
||||
if (!context.player.isPlaying || !context.player.nowTrack) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." }));
|
||||
const numVolume = Number(data.volume);
|
||||
if (isNaN(numVolume)) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume 타입이 올바르지 않습니다." }));
|
||||
if (numVolume < 0) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume은 0보다 크거나 같아야합니다." }));
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Command } from "../types/Command";
|
||||
import { clearAllMsg } from "../utils/music/Utils";
|
||||
import { default_content, default_embed, default_image, getButtons } from "../utils/music/Config";
|
||||
import { DB } from "../utils/Database";
|
||||
import { Logger } from "../utils/Logger";
|
||||
|
||||
/** channel 명령어 */
|
||||
export default class implements Command {
|
||||
@@ -100,7 +101,7 @@ export async function channelRegister(guild: Guild | null, channelId: string | n
|
||||
components: [ getButtons() ],
|
||||
files: [ default_image ],
|
||||
}).catch((err) => {
|
||||
console.error(err);
|
||||
Logger.error(`[Channel] 메세지 생성 실패: ${String(err)}`);
|
||||
return null;
|
||||
});
|
||||
if (!msg) return client.mkembed({
|
||||
|
||||
@@ -72,7 +72,7 @@ export async function channelJoin(guild: Guild | null, voiceChannelId: string |
|
||||
}) };
|
||||
|
||||
let player = lavalinkManager.getPlayer(guild.id);
|
||||
if (player) return { embed: client.mkembed({ title: `이미 <#${player.voiceChannelId} 참가중입니다.` }), player };
|
||||
if (player) return { embed: client.mkembed({ title: `이미 <#${player.voiceChannelId}> 참가중입니다.` }), player };
|
||||
player = new GuildPlayer(
|
||||
guild,
|
||||
await lavalinkManager.shoukaku.joinVoiceChannel({
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { Interaction, MessageFlags } from "discord.js";
|
||||
import { handler } from "../index";
|
||||
import { buttonInteraction } from "../utils/music/Button";
|
||||
import { Logger } from "../utils/Logger";
|
||||
|
||||
export const interactionCreate = async (interaction: Interaction) => {
|
||||
try {
|
||||
if (interaction.isStringSelectMenu()) {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch(() => {});
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch((err) => {
|
||||
Logger.warn(`[Interaction] SelectMenu deferReply 실패: ${String(err)}`);
|
||||
});
|
||||
const commandName = interaction.customId;
|
||||
const args = interaction.values;
|
||||
const command = handler.commands.get(commandName);
|
||||
@@ -17,7 +21,9 @@ export const interactionCreate = async (interaction: Interaction) => {
|
||||
|
||||
if (args[0] === "music") return buttonInteraction(interaction, args.slice(1));
|
||||
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch(() => {});
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch((err) => {
|
||||
Logger.warn(`[Interaction] Button deferReply 실패: ${String(err)}`);
|
||||
});
|
||||
|
||||
const key = args.shift();
|
||||
if (!key) return;
|
||||
@@ -31,6 +37,11 @@ export const interactionCreate = async (interaction: Interaction) => {
|
||||
* 명령어 친사람만 보이게 설정
|
||||
* flags: MessageFlags.Ephemeral
|
||||
*/
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch(() => {});
|
||||
handler.runCommand(interaction);
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch((err) => {
|
||||
Logger.warn(`[Interaction] Command deferReply 실패: ${String(err)}`);
|
||||
});
|
||||
await handler.runCommand(interaction);
|
||||
} catch (err) {
|
||||
Logger.error(`[Interaction] 처리 중 에러: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
// TODO: 음성 상태 변경 이벤트 핸들러 (추후 구현)
|
||||
// import { VoiceState } from "discord.js";
|
||||
// import { client } from "../index";
|
||||
|
||||
// export const voiceStateUpdate = async (oldState: VoiceState, newState: VoiceState): Promise<void> => {
|
||||
// }
|
||||
// export const voiceStateUpdate = async (oldState: VoiceState, newState: VoiceState): Promise<void> => {}
|
||||
|
||||
6
bot/src/types/Command.d.ts
vendored
6
bot/src/types/Command.d.ts
vendored
@@ -1,4 +1,4 @@
|
||||
import { ButtonInteraction, ChatInputApplicationCommandData, ChatInputChatInputCommandInteraction, Message, StringSelectMenuInteraction } from "discord.js";
|
||||
import { ButtonInteraction, ChatInputApplicationCommandData, ChatInputCommandInteraction, Message, StringSelectMenuInteraction } from "discord.js";
|
||||
|
||||
export interface Command {
|
||||
/** 메세지 이름 */
|
||||
@@ -13,9 +13,9 @@ export interface Command {
|
||||
* 등록 메타: JSON 변환된 바디
|
||||
* (빌드 시 toJSON()해서 REST 등록에 사용)
|
||||
*/
|
||||
metaData: RESTPostAPIChatInputApplicationCommandsJSONBody;
|
||||
metaData: ChatInputApplicationCommandData;
|
||||
|
||||
slashRun?: (args: ChatInputChatInputCommandInteraction) => Promise<void>;
|
||||
slashRun?: (args: ChatInputCommandInteraction) => Promise<void>;
|
||||
messageRun?: (message: Message, args: string[]) => Promise<void>;
|
||||
menuRun?: (interaction: StringSelectMenuInteraction, args: string[]) => Promise<void>;
|
||||
buttonRun?: (interaction: ButtonInteraction, args: string[]) => Promise<void>;
|
||||
|
||||
@@ -69,6 +69,8 @@ export const Config = {
|
||||
return this._youtube_cookie;
|
||||
},
|
||||
|
||||
proxyUrl: process.env.PROXY_URL?.trim() || "",
|
||||
|
||||
_redis: {
|
||||
state: process.env.REDIS?.trim()?.toLocaleLowerCase() === "true",
|
||||
host: process.env.REDIS_HOST?.trim(),
|
||||
|
||||
@@ -7,12 +7,18 @@ import { Logger } from "./Logger";
|
||||
|
||||
const database = new Database(Config.dbPath);
|
||||
|
||||
const schemaPath = join(process.cwd(), "db/schema.sql");
|
||||
const schemaPath = join(__dirname, "../../db/schema.sql");
|
||||
const schema = readFileSync(schemaPath, "utf-8");
|
||||
database.exec(schema);
|
||||
|
||||
Logger.ready("DB 활성화!");
|
||||
|
||||
// 허용되는 guild 테이블 컬럼 화이트리스<EBA6AC><EC8AA4><EFBFBD>
|
||||
const GUILD_COLUMNS = new Set(["id", "name", "channel_id", "msg_id", "options"]);
|
||||
|
||||
const filterKeys = (keys: string[], whitelist: Set<string>) =>
|
||||
keys.filter(k => whitelist.has(k));
|
||||
|
||||
const stmt = {
|
||||
guild: {
|
||||
// 전체
|
||||
@@ -21,7 +27,7 @@ const stmt = {
|
||||
get: database.prepare("SELECT * FROM guilds WHERE ID = ?"),
|
||||
// 추가
|
||||
insert: (data: GuildRow) => {
|
||||
const keys = Object.keys(data);
|
||||
const keys = filterKeys(Object.keys(data), GUILD_COLUMNS);
|
||||
if (keys.length === 0) throw new Error("insert: 키1개는 있어야함");
|
||||
return database.prepare(`INSERT INTO guilds (${
|
||||
keys.map(k => `"${k}"`).join(", ")
|
||||
@@ -31,35 +37,13 @@ const stmt = {
|
||||
},
|
||||
// 수정
|
||||
update: (data: GuildRow) => {
|
||||
const keys = Object.keys(data).filter(k => k !== "id");
|
||||
const keys = filterKeys(Object.keys(data), GUILD_COLUMNS).filter(k => k !== "id");
|
||||
if (keys.length === 0) throw new Error("update: 키1개는 있어야함");
|
||||
return database.prepare(`UPDATE guilds SET ${
|
||||
keys.map(k => `${k} = @${k}`).join(", ")
|
||||
} WHERE id = @id`).run(data);
|
||||
},
|
||||
},
|
||||
// user: {
|
||||
// // 가져오기
|
||||
// get: database.prepare("SELECT * FROM users WHERE guild_id = ? AND id = ?"),
|
||||
// // 추가
|
||||
// insert: (data: UserType) => {
|
||||
// const keys = Object.keys(data);
|
||||
// if (keys.length === 0) throw new Error("insert: 키1개는 있어야함");
|
||||
// return database.prepare(`INSERT INTO users (${
|
||||
// keys.map(k => `"${k}"`).join(", ")
|
||||
// }) VALUES (${
|
||||
// keys.map(k => `@${k}`).join(", ")
|
||||
// })`).run(data);
|
||||
// },
|
||||
// // 수정
|
||||
// update: (data: UserType) => {
|
||||
// const keys = Object.keys(data).filter(k => k !== "guild_id" && k !== "id");
|
||||
// if (keys.length === 0) throw new Error("update: 키1개는 있어야함");
|
||||
// return database.prepare(`UPDATE users SET ${
|
||||
// keys.map(k => `${k} = @${k}`).join(", ")
|
||||
// } WHERE guild_id = @guild_id AND id = @id`).run(data);
|
||||
// },
|
||||
// },
|
||||
};
|
||||
|
||||
export const DB = {
|
||||
@@ -91,27 +75,4 @@ export const DB = {
|
||||
}
|
||||
},
|
||||
},
|
||||
// user: {
|
||||
// get(guildId: string, id: string) {
|
||||
// return stmt.user.get.get(guildId, id) as UserType | undefined;
|
||||
// },
|
||||
// set(data: UserType) {
|
||||
// try {
|
||||
// stmt.user.insert(data);
|
||||
// return true;
|
||||
// } catch (err) {
|
||||
// Logger.error(String(err));
|
||||
// return false;
|
||||
// }
|
||||
// },
|
||||
// update(data: UserType) {
|
||||
// try {
|
||||
// stmt.user.update(data);
|
||||
// return true;
|
||||
// } catch (err) {
|
||||
// Logger.error(String(err));
|
||||
// return false;
|
||||
// }
|
||||
// },
|
||||
// },
|
||||
};
|
||||
@@ -1,15 +1,8 @@
|
||||
export const fshuffle = (list: any[]): any[] => {
|
||||
var i, j, x;
|
||||
for (i=list.length; i; i-=1) {
|
||||
j = Math.floor(Math.random()*i);
|
||||
x = list[i-1];
|
||||
list[i-1] = list[j];
|
||||
list[j] = x;
|
||||
/** Fisher-Yates 셔플 (in-place) */
|
||||
export const shuffle = <T>(list: T[]): T[] => {
|
||||
for (let i = list.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[list[i], list[j]] = [list[j], list[i]];
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
export const shuffle = (list: any[]): any[] => {
|
||||
for (let z=0; z<5; z++) list = fshuffle(list);
|
||||
return list;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ const SPOTIFY_SECRET = process.env.SPOTIFY_SECRET?.trim() ?? "";
|
||||
|
||||
const SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token";
|
||||
const SPOTIFY_API_URL = "https://api.spotify.com/v1";
|
||||
const TOKENR_URL = "http://192.168.10.5:8075/api/token";
|
||||
const TOKENR_URL = process.env.SPOTIFY_TOKENER_URL?.trim() || "http://192.168.10.5:8075/api/token";
|
||||
|
||||
const searchCache = new Map<string, string>();
|
||||
|
||||
|
||||
@@ -3,10 +3,11 @@ import crypto from "node:crypto";
|
||||
import { Cookies } from "../../types/Youtube_Cookie";
|
||||
import { Config } from "../Config";
|
||||
import { SongItem } from "../../types/Track";
|
||||
import { Logger } from "../Logger";
|
||||
|
||||
const customPREF = "tz=Asia.Seoul&hl=ko&gl=KR&last_quality=1080";
|
||||
export const ORIGIN = "https://music.youtube.com";
|
||||
const proxy = new ProxyAgent('http://192.168.10.4:3128');
|
||||
const proxy = Config.proxyUrl ? new ProxyAgent(Config.proxyUrl) : undefined;
|
||||
const searchCache = new Map<string, string>();
|
||||
|
||||
// 🌟 클래스 외부에 둘 상수 및 유틸리티 함수들 (내부에서만 사용됨)
|
||||
@@ -56,7 +57,7 @@ export const YoutubeMusic = {
|
||||
const missing = keys.filter((k) => !(k in cookies) && !(blocks ?? []).includes(k));
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.log("현재 입력된 쿠키 키 목록:", Object.keys(cookies));
|
||||
Logger.warn(`현재 입력된 쿠키 키 목록: ${Object.keys(cookies).join(", ")}`);
|
||||
throw new Error(`❌ 필수 인증 쿠키가 누락되었습니다: ${missing.join(", ")}`);
|
||||
}
|
||||
|
||||
@@ -82,7 +83,7 @@ export const YoutubeMusic = {
|
||||
* 완벽한 쿠키 인증과 서명(SAPISIDHASH)을 사용하여 유튜브 뮤직 검색을 수행합니다.
|
||||
*/
|
||||
async getSearchFull(query: string): Promise<SongItem[]> {
|
||||
console.log(`🔍 [Auth-Cookie Engine] "${query}" 데이터 추출 중 (썸네일, 재생시간 포함)...`);
|
||||
Logger.log(`🔍 [Auth-Cookie Engine] "${query}" 데이터 추출 중 (썸네일, 재생시간 포함)...`);
|
||||
|
||||
const url = "https://music.youtube.com/youtubei/v1/search?prettyPrint=false";
|
||||
|
||||
@@ -109,7 +110,7 @@ export const YoutubeMusic = {
|
||||
query: query,
|
||||
params: "EgWKAQIIAWoOEAMQBBAQEAkQFRAKEBE="
|
||||
}),
|
||||
dispatcher: proxy
|
||||
...(proxy ? { dispatcher: proxy } : {})
|
||||
});
|
||||
|
||||
const data: any = await response.json();
|
||||
@@ -200,9 +201,9 @@ export const YoutubeMusic = {
|
||||
}
|
||||
}
|
||||
|
||||
return results || []; // 배열이 비어있을 경우 안전하게 null 반환
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error("❌ getSearchFull 실행 중 에러:", error);
|
||||
Logger.error(`❌ getSearchFull 실행 중 에러: ${String(error)}`);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { lavalinkManager } from "../../index";
|
||||
import { checkTextChannelAndMsg, getTextChannelAndMsg } from "./Channel";
|
||||
import { default_content, default_embed, default_image, getButtons } from "./Config";
|
||||
import { DB } from "../Database";
|
||||
import { Logger } from "../Logger";
|
||||
|
||||
export const buttonInteraction = (interaction: ButtonInteraction, args: string[]) => {
|
||||
if (!interaction.guild) return;
|
||||
@@ -16,7 +17,9 @@ export const buttonInteraction = (interaction: ButtonInteraction, args: string[]
|
||||
} else {
|
||||
if (args[0] === "recommend") buttonRecommend(interaction.guild);
|
||||
}
|
||||
return interaction.deferUpdate().catch(() => {});
|
||||
return interaction.deferUpdate().catch((err) => {
|
||||
Logger.warn(`[Button] deferUpdate 실패: ${String(err)}`);
|
||||
});
|
||||
}
|
||||
|
||||
const buttonRecommend = async (guild: Guild) => {
|
||||
@@ -33,7 +36,7 @@ const buttonRecommend = async (guild: Guild) => {
|
||||
components: [ getButtons() ],
|
||||
files: [ default_image ],
|
||||
}).catch((err) => {
|
||||
console.error(err);
|
||||
Logger.error(`[Button] 메세지 수정 실패: ${String(err)}`);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
@@ -6,15 +6,19 @@ import { clearAllMsg } from "./Utils";
|
||||
import { client } from "../../index";
|
||||
|
||||
export const getGuildById = async (guildId: string): Promise<Guild | null> => {
|
||||
const guild = client.guilds.cache.get(guildId)?.fetch();
|
||||
const guild = await client.guilds.cache.get(guildId)?.fetch().catch(() => undefined);
|
||||
if (!guild) return null;
|
||||
return guild;
|
||||
}
|
||||
|
||||
export const getMemberById = async (guild: Guild, userId: string): Promise<GuildMember | null> => {
|
||||
const member = await guild.members.cache.get(userId)?.fetch(true);
|
||||
if (!member) return null;
|
||||
return member;
|
||||
try {
|
||||
const cached = guild.members.cache.get(userId);
|
||||
if (!cached) return null;
|
||||
return await cached.fetch(true);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const getVoiceChannel = (member: GuildMember): VoiceChannel | null => {
|
||||
@@ -25,9 +29,14 @@ export const getVoiceChannel = (member: GuildMember): VoiceChannel | null => {
|
||||
|
||||
export const getVoiceChannelById = async (guild: Guild, userId: string): Promise<VoiceChannel | null> => {
|
||||
if (!guild) return null;
|
||||
const member = await guild.members.cache.get(userId)?.fetch(true);
|
||||
if (!member) return null;
|
||||
try {
|
||||
const cached = guild.members.cache.get(userId);
|
||||
if (!cached) return null;
|
||||
const member = await cached.fetch(true);
|
||||
return getVoiceChannel(member);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const getTextChannelAndMsg = async (guild: Guild): Promise<{ channel?: TextChannel; msg?: Message; reason?: string; }> => {
|
||||
|
||||
@@ -1,12 +1,38 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
allowedDevOrigins: [
|
||||
"192.168.10.13",
|
||||
"localhost",
|
||||
"music.tkrmagid.kr"
|
||||
]
|
||||
// 보안 헤더
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: [
|
||||
{ key: "X-Frame-Options", value: "DENY" },
|
||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||
{
|
||||
key: "Permissions-Policy",
|
||||
value: "camera=(), microphone=(), geolocation=()",
|
||||
},
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
// 디스코드 CDN(이미지)과 자기 자신만 신뢰
|
||||
value: [
|
||||
"default-src 'self'",
|
||||
"img-src 'self' data: https://cdn.discordapp.com https://i.scdn.co https://i.ytimg.com https://lh3.googleusercontent.com",
|
||||
"script-src 'self' 'unsafe-inline'" + (process.env.NODE_ENV === "production" ? "" : " 'unsafe-eval'"),
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"connect-src 'self' https://discord.com",
|
||||
"font-src 'self' data:",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self' https://discord.com",
|
||||
].join("; "),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
204
page/package-lock.json
generated
204
page/package-lock.json
generated
@@ -11,7 +11,7 @@
|
||||
"colors": "^1.4.0",
|
||||
"ioredis": "^5.10.1",
|
||||
"lucide-react": "^1.7.0",
|
||||
"next": "16.2.2",
|
||||
"next": "^16.2.4",
|
||||
"next-auth": "^4.24.13",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
@@ -22,7 +22,7 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.2",
|
||||
"eslint-config-next": "^16.2.4",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
@@ -611,9 +611,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -630,9 +627,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -649,9 +643,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -668,9 +659,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -687,9 +675,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -706,9 +691,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -725,9 +707,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -744,9 +723,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -763,9 +739,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -788,9 +761,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -813,9 +783,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -838,9 +805,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -863,9 +827,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -888,9 +849,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -913,9 +871,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -938,9 +893,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1102,15 +1054,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "16.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.2.tgz",
|
||||
"integrity": "sha512-LqSGz5+xGk9EL/iBDr2yo/CgNQV6cFsNhRR2xhSXYh7B/hb4nePCxlmDvGEKG30NMHDFf0raqSyOZiQrO7BkHQ==",
|
||||
"version": "16.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz",
|
||||
"integrity": "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
"version": "16.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.2.tgz",
|
||||
"integrity": "sha512-IOPbWzDQ+76AtjZioaCjpIY72xNSDMnarZ2GMQ4wjNLvnJEJHqxQwGFhgnIWLV9klb4g/+amg88Tk5OXVpyLTw==",
|
||||
"version": "16.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.4.tgz",
|
||||
"integrity": "sha512-tOX826JJ96gYK/go18sPUgMq9FK1tqxBFfUCEufJb5XIkWFFmpgU7mahJANKGkHs7F41ir3tReJ3Lv5La0RvhA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1118,9 +1070,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "16.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.2.tgz",
|
||||
"integrity": "sha512-B92G3ulrwmkDSEJEp9+XzGLex5wC1knrmCSIylyVeiAtCIfvEJYiN3v5kXPlYt5R4RFlsfO/v++aKV63Acrugg==",
|
||||
"version": "16.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.4.tgz",
|
||||
"integrity": "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1134,9 +1086,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "16.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.2.tgz",
|
||||
"integrity": "sha512-7ZwSgNKJNQiwW0CKhNm9B1WS2L1Olc4B2XY0hPYCAL3epFnugMhuw5TMWzMilQ3QCZcCHoYm9NGWTHbr5REFxw==",
|
||||
"version": "16.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.4.tgz",
|
||||
"integrity": "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1150,15 +1102,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "16.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.2.tgz",
|
||||
"integrity": "sha512-c3m8kBHMziMgo2fICOP/cd/5YlrxDU5YYjAJeQLyFsCqVF8xjOTH/QYG4a2u48CvvZZSj1eHQfBCbyh7kBr30Q==",
|
||||
"version": "16.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.4.tgz",
|
||||
"integrity": "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1169,15 +1118,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "16.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.2.tgz",
|
||||
"integrity": "sha512-VKLuscm0P/mIfzt+SDdn2+8TNNJ7f0qfEkA+az7OqQbjzKdBxAHs0UvuiVoCtbwX+dqMEL9U54b5wQ/aN3dHeg==",
|
||||
"version": "16.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.4.tgz",
|
||||
"integrity": "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1188,15 +1134,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "16.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.2.tgz",
|
||||
"integrity": "sha512-kU3OPHJq6sBUjOk7wc5zJ7/lipn8yGldMoAv4z67j6ov6Xo/JvzA7L7LCsyzzsXmgLEhk3Qkpwqaq/1+XpNR3g==",
|
||||
"version": "16.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.4.tgz",
|
||||
"integrity": "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1207,15 +1150,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "16.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.2.tgz",
|
||||
"integrity": "sha512-CKXRILyErMtUftp+coGcZ38ZwE/Aqq45VMCcRLr2I4OXKrgxIBDXHnBgeX/UMil0S09i2JXaDL3Q+TN8D/cKmg==",
|
||||
"version": "16.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.4.tgz",
|
||||
"integrity": "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1226,9 +1166,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "16.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.2.tgz",
|
||||
"integrity": "sha512-sS/jSk5VUoShUqINJFvNjVT7JfR5ORYj/+/ZpOYbbIohv/lQfduWnGAycq2wlknbOql2xOR0DoV0s6Xfcy49+g==",
|
||||
"version": "16.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.4.tgz",
|
||||
"integrity": "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1242,9 +1182,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "16.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.2.tgz",
|
||||
"integrity": "sha512-aHaKceJgdySReT7qeck5oShucxWRiiEuwCGK8HHALe6yZga8uyFpLkPgaRw3kkF04U7ROogL/suYCNt/+CuXGA==",
|
||||
"version": "16.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz",
|
||||
"integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1463,9 +1403,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1483,9 +1420,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1503,9 +1437,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1523,9 +1454,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2076,9 +2004,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2093,9 +2018,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2110,9 +2032,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2127,9 +2046,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2144,9 +2060,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2161,9 +2074,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2178,9 +2088,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2195,9 +2102,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3270,13 +3174,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-next": {
|
||||
"version": "16.2.2",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.2.tgz",
|
||||
"integrity": "sha512-6VlvEhwoug2JpVgjZDhyXrJXUEuPY++TddzIpTaIRvlvlXXFgvQUtm3+Zr84IjFm0lXtJt73w19JA08tOaZVwg==",
|
||||
"version": "16.2.4",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.4.tgz",
|
||||
"integrity": "sha512-A6ekXYFj/YQxBPMl45g3e+U8zJo+X2+ZQwcz34pPKjpc/3S4roBA2Rd9xWB4FKuSxhofo1/95WjzmUY+wHrOhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/eslint-plugin-next": "16.2.2",
|
||||
"@next/eslint-plugin-next": "16.2.4",
|
||||
"eslint-import-resolver-node": "^0.3.6",
|
||||
"eslint-import-resolver-typescript": "^3.5.2",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
@@ -4873,9 +4777,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4897,9 +4798,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4921,9 +4819,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4945,9 +4840,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5185,12 +5077,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "16.2.2",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.2.2.tgz",
|
||||
"integrity": "sha512-i6AJdyVa4oQjyvX/6GeER8dpY/xlIV+4NMv/svykcLtURJSy/WzDnnUk/TM4d0uewFHK7xSQz4TbIwPgjky+3A==",
|
||||
"version": "16.2.4",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz",
|
||||
"integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "16.2.2",
|
||||
"@next/env": "16.2.4",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
@@ -5204,14 +5096,14 @@
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "16.2.2",
|
||||
"@next/swc-darwin-x64": "16.2.2",
|
||||
"@next/swc-linux-arm64-gnu": "16.2.2",
|
||||
"@next/swc-linux-arm64-musl": "16.2.2",
|
||||
"@next/swc-linux-x64-gnu": "16.2.2",
|
||||
"@next/swc-linux-x64-musl": "16.2.2",
|
||||
"@next/swc-win32-arm64-msvc": "16.2.2",
|
||||
"@next/swc-win32-x64-msvc": "16.2.2",
|
||||
"@next/swc-darwin-arm64": "16.2.4",
|
||||
"@next/swc-darwin-x64": "16.2.4",
|
||||
"@next/swc-linux-arm64-gnu": "16.2.4",
|
||||
"@next/swc-linux-arm64-musl": "16.2.4",
|
||||
"@next/swc-linux-x64-gnu": "16.2.4",
|
||||
"@next/swc-linux-x64-musl": "16.2.4",
|
||||
"@next/swc-win32-arm64-msvc": "16.2.4",
|
||||
"@next/swc-win32-x64-msvc": "16.2.4",
|
||||
"sharp": "^0.34.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"colors": "^1.4.0",
|
||||
"ioredis": "^5.10.1",
|
||||
"lucide-react": "^1.7.0",
|
||||
"next": "16.2.2",
|
||||
"next": "^16.2.4",
|
||||
"next-auth": "^4.24.13",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
@@ -23,7 +23,7 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.2",
|
||||
"eslint-config-next": "^16.2.4",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
import NextAuth, { NextAuthOptions } from "next-auth";
|
||||
import DiscordProvider from "next-auth/providers/discord";
|
||||
|
||||
// 환경변수 부팅 시점 검증 — 누락 시 즉시 실패
|
||||
const DISCORD_CLIENT_ID = process.env.DISCORD_CLIENT_ID?.trim();
|
||||
const DISCORD_CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET?.trim();
|
||||
const NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET?.trim();
|
||||
|
||||
if (!DISCORD_CLIENT_ID || !DISCORD_CLIENT_SECRET) {
|
||||
throw new Error("[NextAuth] DISCORD_CLIENT_ID/DISCORD_CLIENT_SECRET 환경변수가 설정되지 않았습니다.");
|
||||
}
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("[NextAuth] NEXTAUTH_SECRET 환경변수가 설정되지 않았습니다.");
|
||||
}
|
||||
|
||||
interface DiscordProfile {
|
||||
id?: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
secret: NEXTAUTH_SECRET,
|
||||
providers: [
|
||||
DiscordProvider({
|
||||
clientId: process.env.DISCORD_CLIENT_ID as string,
|
||||
clientSecret: process.env.DISCORD_CLIENT_SECRET as string,
|
||||
clientId: DISCORD_CLIENT_ID,
|
||||
clientSecret: DISCORD_CLIENT_SECRET,
|
||||
// 🌟 핵심: 로그인할 때 유저의 기본 정보(identify)와 서버 목록(guilds) 권한을 같이 가져옵니다!
|
||||
authorization: { params: { scope: "identify email guilds" } },
|
||||
}),
|
||||
@@ -16,15 +35,16 @@ export const authOptions: NextAuthOptions = {
|
||||
callbacks: {
|
||||
// 디스코드에서 받은 토큰(accessToken)을 우리 세션에 저장해두는 로직
|
||||
async jwt({ token, account, profile }) {
|
||||
if (account && (profile as any)?.id) {
|
||||
token.id = (profile as any).id;
|
||||
const discordProfile = profile as DiscordProfile | undefined;
|
||||
if (account && discordProfile?.id) {
|
||||
token.id = discordProfile.id;
|
||||
token.accessToken = account.access_token;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }: any) {
|
||||
session.user.id = token.id;
|
||||
session.accessToken = token.accessToken;
|
||||
async session({ session, token }) {
|
||||
if (token.id) session.user.id = token.id;
|
||||
if (token.accessToken) session.accessToken = token.accessToken;
|
||||
return session;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,54 +1,10 @@
|
||||
// src/app/api/queue/events/route.ts
|
||||
// src/app/api/player/events/route.ts
|
||||
import { NextRequest } from "next/server";
|
||||
import { Redis } from "@/lib/Redis"; // 사용 중인 Redis 클라이언트
|
||||
import { botEventStream } from "@/lib/sse";
|
||||
|
||||
// 이 API는 캐시되지 않고 항상 실시간으로 작동해야 합니다.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
// 프론트엔드에서 보낸 serverId 가져오기
|
||||
const serverId = req.nextUrl.searchParams.get("serverId");
|
||||
|
||||
if (!serverId) {
|
||||
return new Response("Missing serverId", { status: 400 });
|
||||
}
|
||||
|
||||
// SSE(Server-Sent Events) 스트림 생성
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
// 🚨 중요: 구독(Subscribe) 전용으로 쓸 독립적인 Redis 연결을 하나 복제합니다.
|
||||
const subscriber = Redis.duplicate();
|
||||
|
||||
// 'bot-site' 채널 구독
|
||||
await subscriber.subscribe("bot-site");
|
||||
|
||||
// 메세지가 들어올 때마다 실행
|
||||
subscriber.on("message", (channel, message) => {
|
||||
if (channel !== "bot-site") return;
|
||||
const data = JSON.parse(message);
|
||||
if (data.guildId !== serverId) return;
|
||||
// 알림이 울린 서버와 현재 유저가 보고 있는 서버가 일치할 때만!
|
||||
if (data.event === "player_update") {
|
||||
// 프론트엔드로 "새로고침해!" 라는 데이터를 전송
|
||||
controller.enqueue(`data: ${JSON.stringify({ type: "player_update" })}\n\n`);
|
||||
}
|
||||
});
|
||||
|
||||
// 클라이언트(웹사이트)가 브라우저를 닫거나 다른 페이지로 가면 연결 종료 및 정리
|
||||
req.signal.addEventListener("abort", () => {
|
||||
subscriber.unsubscribe("bot-site");
|
||||
subscriber.quit();
|
||||
controller.close();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 스트림 응답 헤더 설정 (연결을 끊지 않고 계속 유지)
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
});
|
||||
return botEventStream(req, { botEventName: "player_update" });
|
||||
}
|
||||
@@ -1,42 +1,34 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { Redis } from "@/lib/Redis";
|
||||
import { Logger } from "@/lib/Logger";
|
||||
import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api";
|
||||
|
||||
interface NowBody {
|
||||
serverId?: unknown;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { serverId, userId } = body;
|
||||
const sessionResult = await requireSession();
|
||||
if (!sessionResult.ok) return sessionResult.response;
|
||||
const userId = sessionResult.session.user.id;
|
||||
|
||||
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
|
||||
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
|
||||
const bodyResult = await readJsonBody<NowBody>(request);
|
||||
if (!bodyResult.ok) return bodyResult.response;
|
||||
|
||||
const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||
const resultKey = `player:now:${requestId}`; // 봇이 대답을 남길 Redis 방 이름
|
||||
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
|
||||
if (!serverIdResult.ok) return serverIdResult.response;
|
||||
|
||||
// 봇에게 'player_now' 명령 전송
|
||||
await Redis.publish("site-bot", JSON.stringify({
|
||||
const { status, body } = await botRpc({
|
||||
channel: "player:now",
|
||||
payload: {
|
||||
action: "player_now",
|
||||
requestId: requestId,
|
||||
serverId: serverId,
|
||||
userId: userId,
|
||||
}));
|
||||
|
||||
// 3. 봇의 대답 기다리기 (최대 약 3초 대기)
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기
|
||||
const botReply = await Redis.get(resultKey);
|
||||
if (botReply) {
|
||||
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달
|
||||
await Redis.del(resultKey);
|
||||
const replyData = JSON.parse(botReply);
|
||||
// replyData.success 가 false면 에러 상태코드(400)로 보냄
|
||||
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// 3초가 지나도 봇이 묵묵부답일 때
|
||||
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
|
||||
serverId: serverIdResult.value,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(body, { status });
|
||||
} catch (error) {
|
||||
console.error("Play API Error:", error);
|
||||
return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 });
|
||||
Logger.error(`player/now API error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return errorResponse("서버 오류가 발생했습니다.", 500);
|
||||
}
|
||||
}
|
||||
@@ -1,43 +1,46 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { Redis } from "@/lib/Redis";
|
||||
import { Logger } from "@/lib/Logger";
|
||||
import {
|
||||
botRpc,
|
||||
errorResponse,
|
||||
readJsonBody,
|
||||
requireBoolean,
|
||||
requireSession,
|
||||
requireString,
|
||||
} from "@/lib/api";
|
||||
|
||||
interface PauseBody {
|
||||
serverId?: unknown;
|
||||
isPaused?: unknown;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { serverId, userId, isPaused } = body;
|
||||
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
|
||||
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
|
||||
if (!isPaused) return NextResponse.json({ error: "isPaused 정보가 필요합니다." }, { status: 400 });
|
||||
const sessionResult = await requireSession();
|
||||
if (!sessionResult.ok) return sessionResult.response;
|
||||
const userId = sessionResult.session.user.id;
|
||||
|
||||
const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||
const resultKey = `player:paused:${requestId}`; // 봇이 대답을 남길 Redis 방 이름
|
||||
const bodyResult = await readJsonBody<PauseBody>(request);
|
||||
if (!bodyResult.ok) return bodyResult.response;
|
||||
|
||||
// 봇에게 'player_pause' 명령 전송
|
||||
await Redis.publish("site-bot", JSON.stringify({
|
||||
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
|
||||
if (!serverIdResult.ok) return serverIdResult.response;
|
||||
|
||||
const isPausedResult = requireBoolean(bodyResult.data.isPaused, "isPaused");
|
||||
if (!isPausedResult.ok) return isPausedResult.response;
|
||||
|
||||
const { status, body } = await botRpc({
|
||||
channel: "player:paused",
|
||||
payload: {
|
||||
action: "player_paused",
|
||||
requestId: requestId,
|
||||
serverId: serverId,
|
||||
userId: userId,
|
||||
isPaused: isPaused,
|
||||
}));
|
||||
|
||||
// 3. 봇의 대답 기다리기 (최대 약 3초 대기)
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기
|
||||
const botReply = await Redis.get(resultKey);
|
||||
if (botReply) {
|
||||
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달
|
||||
await Redis.del(resultKey);
|
||||
const replyData = JSON.parse(botReply);
|
||||
// replyData.success 가 false면 에러 상태코드(400)로 보냄
|
||||
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// 3초가 지나도 봇이 묵묵부답일 때
|
||||
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
|
||||
serverId: serverIdResult.value,
|
||||
userId,
|
||||
isPaused: isPausedResult.value,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(body, { status });
|
||||
} catch (error) {
|
||||
console.error("Play API Error:", error);
|
||||
return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 });
|
||||
Logger.error(`player/pause API error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return errorResponse("서버 오류가 발생했습니다.", 500);
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,39 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { Redis } from "@/lib/Redis";
|
||||
import { Logger } from "@/lib/Logger";
|
||||
import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api";
|
||||
|
||||
interface PlayBody {
|
||||
serverId?: unknown;
|
||||
track?: unknown;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { serverId, userId, track } = body;
|
||||
const sessionResult = await requireSession();
|
||||
if (!sessionResult.ok) return sessionResult.response;
|
||||
const userId = sessionResult.session.user.id;
|
||||
|
||||
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
|
||||
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
|
||||
if (!track) return NextResponse.json({ error: "track 정보가 필요합니다." }, { status: 400 });
|
||||
const bodyResult = await readJsonBody<PlayBody>(request);
|
||||
if (!bodyResult.ok) return bodyResult.response;
|
||||
|
||||
const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||
const resultKey = `player:play:${requestId}`; // 봇이 대답을 남길 Redis 방 이름
|
||||
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
|
||||
if (!serverIdResult.ok) return serverIdResult.response;
|
||||
|
||||
// 봇에게 'player_play' 명령 전송
|
||||
await Redis.publish("site-bot", JSON.stringify({
|
||||
const track = bodyResult.data.track;
|
||||
if (!track || typeof track !== "object") return errorResponse("track 정보가 필요합니다.");
|
||||
|
||||
const { status, body } = await botRpc({
|
||||
channel: "player:play",
|
||||
payload: {
|
||||
action: "player_play",
|
||||
requestId: requestId,
|
||||
serverId: serverId,
|
||||
userId: userId,
|
||||
track: track,
|
||||
}));
|
||||
|
||||
// 3. 봇의 대답 기다리기 (최대 약 3초 대기)
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기
|
||||
const botReply = await Redis.get(resultKey);
|
||||
if (botReply) {
|
||||
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달
|
||||
await Redis.del(resultKey);
|
||||
const replyData = JSON.parse(botReply);
|
||||
// replyData.success 가 false면 에러 상태코드(400)로 보냄
|
||||
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// 3초가 지나도 봇이 묵묵부답일 때
|
||||
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
|
||||
serverId: serverIdResult.value,
|
||||
userId,
|
||||
track,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(body, { status });
|
||||
} catch (error) {
|
||||
console.error("Play API Error:", error);
|
||||
return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 });
|
||||
Logger.error(`player/play API error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return errorResponse("서버 오류가 발생했습니다.", 500);
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,39 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { Redis } from "@/lib/Redis";
|
||||
import { Logger } from "@/lib/Logger";
|
||||
import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api";
|
||||
|
||||
interface PlaylistBody {
|
||||
serverId?: unknown;
|
||||
playlistUrl?: unknown;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { serverId, userId, playlistUrl } = body;
|
||||
const sessionResult = await requireSession();
|
||||
if (!sessionResult.ok) return sessionResult.response;
|
||||
const userId = sessionResult.session.user.id;
|
||||
|
||||
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
|
||||
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
|
||||
if (!playlistUrl) return NextResponse.json({ error: "playlistUrl 정보가 필요합니다." }, { status: 400 });
|
||||
const bodyResult = await readJsonBody<PlaylistBody>(request);
|
||||
if (!bodyResult.ok) return bodyResult.response;
|
||||
|
||||
const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||
const resultKey = `player:playlist:${requestId}`; // 봇이 대답을 남길 Redis 방 이름
|
||||
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
|
||||
if (!serverIdResult.ok) return serverIdResult.response;
|
||||
|
||||
// 봇에게 'player_playlist' 명령 전송
|
||||
await Redis.publish("site-bot", JSON.stringify({
|
||||
const urlResult = requireString(bodyResult.data.playlistUrl, "playlistUrl");
|
||||
if (!urlResult.ok) return urlResult.response;
|
||||
|
||||
const { status, body } = await botRpc({
|
||||
channel: "player:playlist",
|
||||
payload: {
|
||||
action: "player_playlist",
|
||||
requestId: requestId,
|
||||
serverId: serverId,
|
||||
userId: userId,
|
||||
playlistUrl: playlistUrl,
|
||||
}));
|
||||
|
||||
// 3. 봇의 대답 기다리기 (최대 약 3초 대기)
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기
|
||||
const botReply = await Redis.get(resultKey);
|
||||
if (botReply) {
|
||||
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달
|
||||
await Redis.del(resultKey);
|
||||
const replyData = JSON.parse(botReply);
|
||||
// replyData.success 가 false면 에러 상태코드(400)로 보냄
|
||||
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// 3초가 지나도 봇이 묵묵부답일 때
|
||||
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
|
||||
serverId: serverIdResult.value,
|
||||
userId,
|
||||
playlistUrl: urlResult.value,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(body, { status });
|
||||
} catch (error) {
|
||||
console.error("Queue Adds API Error:", error);
|
||||
return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 });
|
||||
Logger.error(`player/playlist API error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return errorResponse("서버 오류가 발생했습니다.", 500);
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,47 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { Redis } from "@/lib/Redis";
|
||||
import { Logger } from "@/lib/Logger";
|
||||
import {
|
||||
botRpc,
|
||||
errorResponse,
|
||||
readJsonBody,
|
||||
requireNumber,
|
||||
requireSession,
|
||||
requireString,
|
||||
} from "@/lib/api";
|
||||
|
||||
interface SeekBody {
|
||||
serverId?: unknown;
|
||||
seek?: unknown;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { serverId, userId, seek } = body;
|
||||
const sessionResult = await requireSession();
|
||||
if (!sessionResult.ok) return sessionResult.response;
|
||||
const userId = sessionResult.session.user.id;
|
||||
|
||||
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
|
||||
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
|
||||
if (!seek) return NextResponse.json({ error: "seek 정보가 필요합니다." }, { status: 400 });
|
||||
const bodyResult = await readJsonBody<SeekBody>(request);
|
||||
if (!bodyResult.ok) return bodyResult.response;
|
||||
|
||||
const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||
const resultKey = `player:seek:${requestId}`; // 봇이 대답을 남길 Redis 방 이름
|
||||
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
|
||||
if (!serverIdResult.ok) return serverIdResult.response;
|
||||
|
||||
// 봇에게 'player_seek' 명령 전송
|
||||
await Redis.publish("site-bot", JSON.stringify({
|
||||
// seek 는 0(처음으로 되감기) 도 정상 입력. requireNumber 는 0 허용.
|
||||
const seekResult = requireNumber(bodyResult.data.seek, "seek", { min: 0, integer: true });
|
||||
if (!seekResult.ok) return seekResult.response;
|
||||
|
||||
const { status, body } = await botRpc({
|
||||
channel: "player:seek",
|
||||
payload: {
|
||||
action: "player_seek",
|
||||
requestId: requestId,
|
||||
serverId: serverId,
|
||||
userId: userId,
|
||||
seek: seek,
|
||||
}));
|
||||
|
||||
// 3. 봇의 대답 기다리기 (최대 약 3초 대기)
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기
|
||||
const botReply = await Redis.get(resultKey);
|
||||
if (botReply) {
|
||||
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달
|
||||
await Redis.del(resultKey);
|
||||
const replyData = JSON.parse(botReply);
|
||||
// replyData.success 가 false면 에러 상태코드(400)로 보냄
|
||||
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// 3초가 지나도 봇이 묵묵부답일 때
|
||||
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
|
||||
serverId: serverIdResult.value,
|
||||
userId,
|
||||
seek: seekResult.value,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(body, { status });
|
||||
} catch (error) {
|
||||
console.error("Play API Error:", error);
|
||||
return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 });
|
||||
Logger.error(`player/seek API error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return errorResponse("서버 오류가 발생했습니다.", 500);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,34 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { Redis } from "@/lib/Redis";
|
||||
import { Logger } from "@/lib/Logger";
|
||||
import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api";
|
||||
|
||||
interface SkipBody {
|
||||
serverId?: unknown;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { serverId, userId } = body;
|
||||
const sessionResult = await requireSession();
|
||||
if (!sessionResult.ok) return sessionResult.response;
|
||||
const userId = sessionResult.session.user.id;
|
||||
|
||||
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
|
||||
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
|
||||
const bodyResult = await readJsonBody<SkipBody>(request);
|
||||
if (!bodyResult.ok) return bodyResult.response;
|
||||
|
||||
const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||
const resultKey = `player:skip:${requestId}`; // 봇이 대답을 남길 Redis 방 이름
|
||||
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
|
||||
if (!serverIdResult.ok) return serverIdResult.response;
|
||||
|
||||
// 봇에게 'player_skip' 명령 전송
|
||||
await Redis.publish("site-bot", JSON.stringify({
|
||||
const { status, body } = await botRpc({
|
||||
channel: "player:skip",
|
||||
payload: {
|
||||
action: "player_skip",
|
||||
requestId: requestId,
|
||||
serverId: serverId,
|
||||
userId: userId,
|
||||
}));
|
||||
|
||||
// 3. 봇의 대답 기다리기 (최대 약 3초 대기)
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기
|
||||
const botReply = await Redis.get(resultKey);
|
||||
if (botReply) {
|
||||
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달
|
||||
await Redis.del(resultKey);
|
||||
const replyData = JSON.parse(botReply);
|
||||
// replyData.success 가 false면 에러 상태코드(400)로 보냄
|
||||
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// 3초가 지나도 봇이 묵묵부답일 때
|
||||
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
|
||||
serverId: serverIdResult.value,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(body, { status });
|
||||
} catch (error) {
|
||||
console.error("Play API Error:", error);
|
||||
return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 });
|
||||
Logger.error(`player/skip API error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return errorResponse("서버 오류가 발생했습니다.", 500);
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,50 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { Redis } from "@/lib/Redis";
|
||||
import { Logger } from "@/lib/Logger";
|
||||
import {
|
||||
botRpc,
|
||||
errorResponse,
|
||||
readJsonBody,
|
||||
requireNumber,
|
||||
requireSession,
|
||||
requireString,
|
||||
} from "@/lib/api";
|
||||
|
||||
interface VolumeBody {
|
||||
serverId?: unknown;
|
||||
volume?: unknown;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { serverId, userId, volume } = body;
|
||||
const sessionResult = await requireSession();
|
||||
if (!sessionResult.ok) return sessionResult.response;
|
||||
const userId = sessionResult.session.user.id;
|
||||
|
||||
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
|
||||
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
|
||||
if (!volume) return NextResponse.json({ error: "volume 정보가 필요합니다." }, { status: 400 });
|
||||
const bodyResult = await readJsonBody<VolumeBody>(request);
|
||||
if (!bodyResult.ok) return bodyResult.response;
|
||||
|
||||
const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||
const resultKey = `player:volume:${requestId}`; // 봇이 대답을 남길 Redis 방 이름
|
||||
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
|
||||
if (!serverIdResult.ok) return serverIdResult.response;
|
||||
|
||||
// 봇에게 'player_volume' 명령 전송
|
||||
await Redis.publish("site-bot", JSON.stringify({
|
||||
const volumeResult = requireNumber(bodyResult.data.volume, "volume", {
|
||||
min: 0,
|
||||
max: 100,
|
||||
integer: true,
|
||||
});
|
||||
if (!volumeResult.ok) return volumeResult.response;
|
||||
|
||||
const { status, body } = await botRpc({
|
||||
channel: "player:volume",
|
||||
payload: {
|
||||
action: "player_volume",
|
||||
requestId: requestId,
|
||||
serverId: serverId,
|
||||
userId: userId,
|
||||
volume: volume,
|
||||
}));
|
||||
|
||||
// 3. 봇의 대답 기다리기 (최대 약 3초 대기)
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기
|
||||
const botReply = await Redis.get(resultKey);
|
||||
if (botReply) {
|
||||
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달
|
||||
await Redis.del(resultKey);
|
||||
const replyData = JSON.parse(botReply);
|
||||
// replyData.success 가 false면 에러 상태코드(400)로 보냄
|
||||
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// 3초가 지나도 봇이 묵묵부답일 때
|
||||
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
|
||||
serverId: serverIdResult.value,
|
||||
userId,
|
||||
volume: volumeResult.value,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(body, { status });
|
||||
} catch (error) {
|
||||
console.error("Play API Error:", error);
|
||||
return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 });
|
||||
Logger.error(`player/volume API error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return errorResponse("서버 오류가 발생했습니다.", 500);
|
||||
}
|
||||
}
|
||||
@@ -1,54 +1,10 @@
|
||||
// src/app/api/queue/events/route.ts
|
||||
import { NextRequest } from "next/server";
|
||||
import { Redis } from "@/lib/Redis"; // 사용 중인 Redis 클라이언트
|
||||
import { botEventStream } from "@/lib/sse";
|
||||
|
||||
// 이 API는 캐시되지 않고 항상 실시간으로 작동해야 합니다.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
// 프론트엔드에서 보낸 serverId 가져오기
|
||||
const serverId = req.nextUrl.searchParams.get("serverId");
|
||||
|
||||
if (!serverId) {
|
||||
return new Response("Missing serverId", { status: 400 });
|
||||
}
|
||||
|
||||
// SSE(Server-Sent Events) 스트림 생성
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
// 🚨 중요: 구독(Subscribe) 전용으로 쓸 독립적인 Redis 연결을 하나 복제합니다.
|
||||
const subscriber = Redis.duplicate();
|
||||
|
||||
// 'bot-site' 채널 구독
|
||||
await subscriber.subscribe("bot-site");
|
||||
|
||||
// 메세지가 들어올 때마다 실행
|
||||
subscriber.on("message", (channel, message) => {
|
||||
if (channel !== "bot-site") return;
|
||||
const data = JSON.parse(message);
|
||||
if (data.guildId !== serverId) return;
|
||||
// 알림이 울린 서버와 현재 유저가 보고 있는 서버가 일치할 때만!
|
||||
if (data.event === "queue_update") {
|
||||
// 프론트엔드로 "새로고침해!" 라는 데이터를 전송
|
||||
controller.enqueue(`data: ${JSON.stringify({ type: "queue_update" })}\n\n`);
|
||||
}
|
||||
});
|
||||
|
||||
// 클라이언트(웹사이트)가 브라우저를 닫거나 다른 페이지로 가면 연결 종료 및 정리
|
||||
req.signal.addEventListener("abort", () => {
|
||||
subscriber.unsubscribe("bot-site");
|
||||
subscriber.quit();
|
||||
controller.close();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 스트림 응답 헤더 설정 (연결을 끊지 않고 계속 유지)
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
});
|
||||
return botEventStream(req, { botEventName: "queue_update" });
|
||||
}
|
||||
@@ -1,44 +1,34 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { Redis } from "@/lib/Redis";
|
||||
import { Logger } from "@/lib/Logger";
|
||||
import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api";
|
||||
|
||||
interface QueueListBody {
|
||||
serverId?: unknown;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { serverId, userId } = body;
|
||||
const sessionResult = await requireSession();
|
||||
if (!sessionResult.ok) return sessionResult.response;
|
||||
const userId = sessionResult.session.user.id;
|
||||
|
||||
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
|
||||
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
|
||||
const bodyResult = await readJsonBody<QueueListBody>(request);
|
||||
if (!bodyResult.ok) return bodyResult.response;
|
||||
|
||||
// 1. 고유한 요청 ID(진동벨) 생성
|
||||
const requestId = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||
const resultKey = `queue:list:${requestId}`;
|
||||
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
|
||||
if (!serverIdResult.ok) return serverIdResult.response;
|
||||
|
||||
// 2. 봇에게 'queue_list' 명령 발송
|
||||
await Redis.publish("site-bot", JSON.stringify({
|
||||
const { status, body } = await botRpc({
|
||||
channel: "queue:list",
|
||||
payload: {
|
||||
action: "queue_list",
|
||||
serverId: serverId,
|
||||
userId: userId,
|
||||
requestId: requestId, // 🌟 봇이 대답을 남길 키
|
||||
}));
|
||||
|
||||
// 3. 봇의 대답 기다리기 (최대 약 3초 대기)
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기
|
||||
const botReply = await Redis.get(resultKey);
|
||||
if (botReply) {
|
||||
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달
|
||||
await Redis.del(resultKey);
|
||||
const replyData = JSON.parse(botReply);
|
||||
// replyData.success 가 false면 에러 상태코드(400)로 보냄
|
||||
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// 3초가 지나도 봇이 묵묵부답일 때
|
||||
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
|
||||
|
||||
serverId: serverIdResult.value,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(body, { status });
|
||||
} catch (error) {
|
||||
console.error("Queue List API Error:", error);
|
||||
return NextResponse.json({ success: false, message: "서버 오류가 발생했습니다." }, { status: 500 });
|
||||
Logger.error(`queue/list API error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return errorResponse("서버 오류가 발생했습니다.", 500);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +1,48 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { Redis } from "@/lib/Redis";
|
||||
import { Logger } from "@/lib/Logger";
|
||||
import {
|
||||
botRpc,
|
||||
errorResponse,
|
||||
readJsonBody,
|
||||
requireNumber,
|
||||
requireSession,
|
||||
requireString,
|
||||
} from "@/lib/api";
|
||||
|
||||
interface QueueRemoveBody {
|
||||
serverId?: unknown;
|
||||
index?: unknown;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { serverId, index, userId } = body;
|
||||
const sessionResult = await requireSession();
|
||||
if (!sessionResult.ok) return sessionResult.response;
|
||||
const userId = sessionResult.session.user.id;
|
||||
|
||||
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
|
||||
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
|
||||
if (!index) return NextResponse.json({ error: "index 정보가 필요합니다." }, { status: 400 });
|
||||
const bodyResult = await readJsonBody<QueueRemoveBody>(request);
|
||||
if (!bodyResult.ok) return bodyResult.response;
|
||||
|
||||
const requestId = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||
const resultKey = `queue:remove:${requestId}`;
|
||||
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
|
||||
if (!serverIdResult.ok) return serverIdResult.response;
|
||||
|
||||
// 봇에게 'remove_queue' 명령 발송 (몇 번째 인덱스를 지워라)
|
||||
await Redis.publish("site-bot", JSON.stringify({
|
||||
// index 0 도 정상값
|
||||
const indexResult = requireNumber(bodyResult.data.index, "index", { min: 0, integer: true });
|
||||
if (!indexResult.ok) return indexResult.response;
|
||||
|
||||
const { status, body } = await botRpc({
|
||||
channel: "queue:remove",
|
||||
payload: {
|
||||
action: "queue_remove",
|
||||
serverId: serverId,
|
||||
requestId: requestId,
|
||||
userId: userId,
|
||||
index: index,
|
||||
}));
|
||||
|
||||
// 4. 결과가 올라올 때까지 기다리기 (Polling)
|
||||
// 최대 10번(약 5초) 동안 0.5초 간격으로 확인합니다.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
// 0.5초 대기
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Redis 게시판 확인
|
||||
const resultData = await Redis.get(resultKey);
|
||||
|
||||
if (resultData) {
|
||||
// 🌟 봇이 결과를 올렸다면! 데이터를 돌려주고 종료
|
||||
return NextResponse.json(JSON.parse(resultData));
|
||||
}
|
||||
}
|
||||
|
||||
// 5초가 지나도 응답이 없으면 타임아웃
|
||||
return NextResponse.json({ error: "봇이 검색에 응답하지 않습니다." }, { status: 504 });
|
||||
serverId: serverIdResult.value,
|
||||
userId,
|
||||
index: indexResult.value,
|
||||
},
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
return NextResponse.json(body, { status });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "서버 오류" }, { status: 500 });
|
||||
Logger.error(`queue/remove API error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return errorResponse("서버 오류가 발생했습니다.", 500);
|
||||
}
|
||||
}
|
||||
@@ -1,46 +1,40 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { Redis } from "@/lib/Redis";
|
||||
import { Logger } from "@/lib/Logger";
|
||||
import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api";
|
||||
|
||||
interface QueueSetBody {
|
||||
serverId?: unknown;
|
||||
newQueue?: unknown;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { serverId, newQueue, userId } = body;
|
||||
const sessionResult = await requireSession();
|
||||
if (!sessionResult.ok) return sessionResult.response;
|
||||
const userId = sessionResult.session.user.id;
|
||||
|
||||
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
|
||||
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
|
||||
if (newQueue === undefined || newQueue === null) return NextResponse.json({ error: "newQueue 정보가 필요합니다." }, { status: 400 });
|
||||
const bodyResult = await readJsonBody<QueueSetBody>(request);
|
||||
if (!bodyResult.ok) return bodyResult.response;
|
||||
|
||||
const requestId = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||
const resultKey = `queue:set:${requestId}`;
|
||||
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
|
||||
if (!serverIdResult.ok) return serverIdResult.response;
|
||||
|
||||
// 봇에게 'queue_set' 명령 발송 (전체 대기열을 통째로 덮어써라!)
|
||||
await Redis.publish("site-bot", JSON.stringify({
|
||||
const newQueue = bodyResult.data.newQueue;
|
||||
if (!Array.isArray(newQueue)) return errorResponse("newQueue 정보가 필요합니다.");
|
||||
|
||||
const { status, body } = await botRpc({
|
||||
channel: "queue:set",
|
||||
payload: {
|
||||
action: "queue_set",
|
||||
serverId: serverId,
|
||||
requestId: requestId,
|
||||
userId: userId,
|
||||
newQueue: newQueue,
|
||||
}));
|
||||
|
||||
// 4. 결과가 올라올 때까지 기다리기 (Polling)
|
||||
// 최대 10번(약 5초) 동안 0.5초 간격으로 확인합니다.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
// 0.5초 대기
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Redis 게시판 확인
|
||||
const resultData = await Redis.get(resultKey);
|
||||
|
||||
if (resultData) {
|
||||
// 🌟 봇이 결과를 올렸다면! 데이터를 돌려주고 종료
|
||||
return NextResponse.json(JSON.parse(resultData));
|
||||
}
|
||||
}
|
||||
|
||||
// 5초가 지나도 응답이 없으면 타임아웃
|
||||
return NextResponse.json({ error: "봇이 검색에 응답하지 않습니다." }, { status: 504 });
|
||||
serverId: serverIdResult.value,
|
||||
userId,
|
||||
newQueue,
|
||||
},
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
return NextResponse.json(body, { status });
|
||||
} catch (error) {
|
||||
console.error("Queue Reorder API Error:", error);
|
||||
return NextResponse.json({ error: "서버 오류" }, { status: 500 });
|
||||
Logger.error(`queue/set API error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return errorResponse("서버 오류가 발생했습니다.", 500);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,30 @@
|
||||
// src/app/api/search/route.ts
|
||||
import { NextResponse } from "next/server";
|
||||
import { Redis } from "@/lib/Redis";
|
||||
import { Logger } from "@/lib/Logger";
|
||||
import { botRpc, errorResponse, requireSession } from "@/lib/api";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
// 1. 검색어(query) 가져오기
|
||||
try {
|
||||
const sessionResult = await requireSession();
|
||||
if (!sessionResult.ok) return sessionResult.response;
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get("q");
|
||||
const query = searchParams.get("q")?.trim();
|
||||
|
||||
if (!query) {
|
||||
return NextResponse.json({ error: "검색어가 없습니다." }, { status: 400 });
|
||||
}
|
||||
if (!query) return errorResponse("검색어가 없습니다.", 400);
|
||||
|
||||
// 2. 고유한 요청 ID 생성 (예: 1690001234567-abc)
|
||||
const requestId = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||
const resultKey = `search:${requestId}`;
|
||||
|
||||
// 3. 봇에게 'site-bot' 채널로 검색 명령 발송 (Publish)
|
||||
await Redis.publish("site-bot", JSON.stringify({
|
||||
const { status, body } = await botRpc({
|
||||
channel: "search",
|
||||
payload: {
|
||||
action: "search",
|
||||
query: query,
|
||||
requestId: requestId,
|
||||
}));
|
||||
|
||||
// 4. 결과가 올라올 때까지 기다리기 (Polling)
|
||||
// 최대 10번(약 10초) 동안 1.0초 간격으로 확인합니다.
|
||||
for (let i=0; i<10; i++) {
|
||||
// 1.0초 대기
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Redis 게시판 확인
|
||||
const resultData = await Redis.get(resultKey);
|
||||
|
||||
if (resultData) {
|
||||
// 🌟 봇이 결과를 올렸다면! 데이터를 돌려주고 종료
|
||||
return NextResponse.json(JSON.parse(resultData));
|
||||
query,
|
||||
},
|
||||
timeoutMs: 10000,
|
||||
pollIntervalMs: 250,
|
||||
});
|
||||
return NextResponse.json(body, { status });
|
||||
} catch (error) {
|
||||
Logger.error(`search API error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return errorResponse("서버 오류가 발생했습니다.", 500);
|
||||
}
|
||||
}
|
||||
|
||||
// 5초가 지나도 응답이 없으면 타임아웃
|
||||
return NextResponse.json({ error: "봇이 검색에 응답하지 않습니다." }, { status: 504 });
|
||||
}
|
||||
@@ -1,34 +1,58 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { Redis } from "@/lib/Redis";
|
||||
import { authOptions } from "../auth/[...nextauth]/route";
|
||||
import { Logger } from "@/lib/Logger";
|
||||
import { requireSession } from "@/lib/api";
|
||||
|
||||
interface DiscordGuild {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
owner: boolean;
|
||||
permissions: string;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const session = await getServerSession(authOptions) as any;
|
||||
const sessionResult = await requireSession();
|
||||
if (!sessionResult.ok) return sessionResult.response;
|
||||
|
||||
if (!session || !session.accessToken) {
|
||||
return NextResponse.json({ error: "인증되지 않았습니다." }, { status: 401 });
|
||||
const accessToken = sessionResult.session.accessToken;
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ success: false, error: "Discord 액세스 토큰이 없습니다." }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 디스코드 API에서 유저가 속한 서버 목록 가져오기
|
||||
const userGuildsRes = await fetch("https://discord.com/api/users/@me/guilds", {
|
||||
headers: { Authorization: `Bearer ${session.accessToken}` },
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
const userGuilds = await userGuildsRes.json() ?? [];
|
||||
if (!userGuildsRes.ok) {
|
||||
Logger.warn(`Discord guilds API ${userGuildsRes.status} ${userGuildsRes.statusText}`);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Discord 서버 목록을 가져오지 못했습니다." },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
const userGuildsRaw: unknown = await userGuildsRes.json();
|
||||
const userGuilds: DiscordGuild[] = Array.isArray(userGuildsRaw) ? (userGuildsRaw as DiscordGuild[]) : [];
|
||||
|
||||
// 2. Redis에서 봇이 속한 서버 목록(화이트리스트) 가져오기
|
||||
const botGuildsData = await Redis.get("bot-guilds");
|
||||
const botGuildIds: string[] = botGuildsData ? JSON.parse(botGuildsData) : [];
|
||||
let botGuildIds: string[] = [];
|
||||
if (botGuildsData) {
|
||||
try {
|
||||
const parsed = JSON.parse(botGuildsData);
|
||||
if (Array.isArray(parsed)) botGuildIds = parsed.filter((v): v is string => typeof v === "string");
|
||||
} catch {
|
||||
Logger.warn("Redis bot-guilds JSON 파싱 실패");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 🌟 두 목록을 비교해서 봇이 있는 서버만 필터링!
|
||||
const filteredGuilds = userGuilds.filter((guild: any) =>
|
||||
botGuildIds.includes(guild.id)
|
||||
);
|
||||
const filteredGuilds = userGuilds.filter((guild) => botGuildIds.includes(guild.id));
|
||||
|
||||
return NextResponse.json(filteredGuilds);
|
||||
} catch (error) {
|
||||
console.error("서버 필터링 에러:", error);
|
||||
return NextResponse.json({ error: "서버 목록을 가져오지 못했습니다." }, { status: 500 });
|
||||
Logger.error(`서버 필터링 에러: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return NextResponse.json({ success: false, error: "서버 목록을 가져오지 못했습니다." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,14 @@ import LeftSidebar from "@/components/layout/LeftSidebar";
|
||||
import MainContent from "@/components/player/MainContent";
|
||||
import QueueSidebar from "@/components/player/QueueSidebar";
|
||||
import PlayerBar from "@/components/player/PlayerBar";
|
||||
import type { DiscordServer } from "@/types/music";
|
||||
|
||||
// 화면 모드 타입 정의
|
||||
export type ViewMode = "SERVER_LIST" | "SERVER_DETAIL" | "SEARCH_RESULT";
|
||||
|
||||
export default function MusicPlayerLayout() {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("SERVER_LIST");
|
||||
const [selectedServer, setSelectedServer] = useState<any>(null);
|
||||
const [selectedServer, setSelectedServer] = useState<DiscordServer | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
// 홈 버튼 클릭 시: 서버 목록(또는 상세)으로 복귀
|
||||
@@ -33,7 +34,7 @@ export default function MusicPlayerLayout() {
|
||||
};
|
||||
|
||||
// 서버 선택 시
|
||||
const handleSelectServer = (server: any) => {
|
||||
const handleSelectServer = (server: DiscordServer) => {
|
||||
setSelectedServer(server);
|
||||
setViewMode("SERVER_DETAIL");
|
||||
};
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
"use client";
|
||||
import { ListMusic, Library } from "lucide-react";
|
||||
import { ListMusic } from "lucide-react";
|
||||
|
||||
export default function LeftSidebar() {
|
||||
return (
|
||||
<aside className="w-60 bg-black p-6 flex flex-col gap-6">
|
||||
<nav className="flex flex-col gap-4 text-neutral-400 font-medium">
|
||||
<button className="flex items-center gap-3 hover:text-white transition-colors text-left">
|
||||
<Library size={20} /> 내 플레이리스트
|
||||
</button>
|
||||
<button className="flex items-center gap-3 hover:text-white transition-colors text-left">
|
||||
<ListMusic size={20} /> 좋아요 표시한 곡
|
||||
</button>
|
||||
</nav>
|
||||
<div className="flex items-center gap-2 text-neutral-400 font-medium">
|
||||
<ListMusic size={20} />
|
||||
<span>음악 봇 컨트롤</span>
|
||||
</div>
|
||||
|
||||
<hr className="border-neutral-800" />
|
||||
|
||||
<div className="flex flex-col gap-3 text-sm text-neutral-400 overflow-y-auto">
|
||||
<p className="hover:text-white cursor-pointer truncate">출근길 노동요 모음</p>
|
||||
<p className="hover:text-white cursor-pointer truncate">2024 빌보드 탑 100</p>
|
||||
<p className="hover:text-white cursor-pointer truncate">비 오는 날 듣기 좋은 재즈</p>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 leading-relaxed">
|
||||
상단 검색창에서 노래를 찾아 우측 대기열에 추가할 수 있습니다.
|
||||
디스코드 음성 채널에 입장한 상태에서 사용해주세요.
|
||||
</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,12 @@
|
||||
import { Search, ListMusic, LogIn, LogOut, Home } from "lucide-react";
|
||||
import { signIn, signOut, useSession } from "next-auth/react";
|
||||
import { useState } from "react";
|
||||
import type { DiscordServer } from "@/types/music";
|
||||
|
||||
interface TopNavProps {
|
||||
onSearch: (query: string) => void;
|
||||
onHome: () => void;
|
||||
selectedServer: any; // 🌟 추가: 선택된 서버 정보
|
||||
selectedServer: DiscordServer | null; // 🌟 추가: 선택된 서버 정보
|
||||
}
|
||||
|
||||
export default function TopNav({ onSearch, onHome, selectedServer }: TopNavProps) {
|
||||
|
||||
@@ -5,21 +5,16 @@ import { Play, ChevronLeft, Server, Music, Loader2, SearchX, MonitorPlay, Disc }
|
||||
import { ViewMode } from "@/app/page";
|
||||
// 🌟 [추가됨] 전역 토스트 훅 불러오기
|
||||
import { useToast } from "@/components/ToastProvider";
|
||||
import type { DiscordServer, SearchTrack, SearchResults } from "@/types/music";
|
||||
|
||||
interface MainContentProps {
|
||||
viewMode: ViewMode;
|
||||
setViewMode: (mode: ViewMode) => void;
|
||||
selectedServer: any;
|
||||
setSelectedServer: (server: any) => void;
|
||||
selectedServer: DiscordServer | null;
|
||||
setSelectedServer: (server: DiscordServer | null) => void;
|
||||
searchQuery: string;
|
||||
setSearchQuery: (query: string) => void;
|
||||
onSelectServer: (server: any) => void;
|
||||
}
|
||||
|
||||
interface SearchResultsType {
|
||||
spotify: any[];
|
||||
youtubeMusic: any[];
|
||||
youtubeVideo: any[];
|
||||
onSelectServer: (server: DiscordServer) => void;
|
||||
}
|
||||
|
||||
export default function MainContent({
|
||||
@@ -36,27 +31,31 @@ export default function MainContent({
|
||||
// 🌟 [추가됨] 훅을 실행해서 showToast 함수 꺼내기
|
||||
const { showToast } = useToast();
|
||||
|
||||
const [servers, setServers] = useState<any[]>([]);
|
||||
const [servers, setServers] = useState<DiscordServer[]>([]);
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
|
||||
const [searchResults, setSearchResults] = useState<SearchResultsType>({
|
||||
const [searchResults, setSearchResults] = useState<SearchResults>({
|
||||
spotify: [],
|
||||
youtubeMusic: [],
|
||||
youtubeVideo: []
|
||||
});
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
const handleMusicAction = async (actionType: 'player_play' | 'player_playlist', track?: any, playlistUrl?: string) => {
|
||||
const handleMusicAction = async (actionType: 'player_play' | 'player_playlist', track?: SearchTrack, playlistUrl?: string) => {
|
||||
if (!selectedServer) {
|
||||
// 🌟 alert -> showToast 교체
|
||||
showToast("명령을 내릴 디스코드 서버가 선택되지 않았습니다.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = (session?.user as any)?.id;
|
||||
const userId = session?.user?.id;
|
||||
if (!userId) {
|
||||
showToast("로그인이 필요합니다.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
let endpoint = "";
|
||||
let bodyData: any = { serverId: selectedServer.id, userId: userId };
|
||||
const bodyData: Record<string, unknown> = { serverId: selectedServer.id, userId };
|
||||
if (actionType === 'player_play') {
|
||||
endpoint = "/api/player/play";
|
||||
bodyData.track = track;
|
||||
@@ -88,13 +87,16 @@ export default function MainContent({
|
||||
}
|
||||
};
|
||||
|
||||
const getPermissionLabel = (server: any) => {
|
||||
const getPermissionLabel = (server: DiscordServer | null) => {
|
||||
if (!server) return "알 수 없음";
|
||||
if (server.owner) return "👑 서버 주인";
|
||||
// Discord permissions 는 큰 정수 문자열로 도착함. 숫자/문자열만 받아 안전하게 BigInt 화.
|
||||
const raw: unknown = server.permissions;
|
||||
if (typeof raw !== "string" && typeof raw !== "number") return "👤 일반 멤버";
|
||||
try {
|
||||
const perms = BigInt(server.permissions);
|
||||
if ((perms & BigInt(0x8)) === BigInt(0x8)) return "🛠️ 관리자";
|
||||
if ((perms & BigInt(0x20)) === BigInt(0x20)) return "⚙️ 매니저";
|
||||
const perms = BigInt(raw);
|
||||
if ((perms & 0x8n) === 0x8n) return "🛠️ 관리자";
|
||||
if ((perms & 0x20n) === 0x20n) return "⚙️ 매니저";
|
||||
return "👤 일반 멤버";
|
||||
} catch {
|
||||
return "👤 일반 멤버";
|
||||
@@ -159,7 +161,7 @@ export default function MainContent({
|
||||
|
||||
const hasAnyResults = searchResults.spotify.length > 0 || searchResults.youtubeMusic.length > 0 || searchResults.youtubeVideo.length > 0;
|
||||
|
||||
const renderTrackCard = (track: any) => (
|
||||
const renderTrackCard = (track: SearchTrack) => (
|
||||
<div key={track.videoId || track.id} className="bg-neutral-800/40 p-3 rounded-xl hover:bg-neutral-800 transition-all group border border-transparent hover:border-neutral-700 shadow-md w-full">
|
||||
<div className="aspect-square bg-neutral-700 rounded-md mb-2 relative overflow-hidden shadow-lg">
|
||||
{track.thumbnail && <img src={track.thumbnail} className="w-full h-full object-cover" alt={track.title} />}
|
||||
@@ -258,7 +260,7 @@ export default function MainContent({
|
||||
{/* 화면 3: 검색 결과 목록 (3가지 카테고리로 분할) */}
|
||||
{viewMode === "SEARCH_RESULT" && (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-2">"{searchQuery}" 검색 결과</h2>
|
||||
<h2 className="text-2xl font-bold mb-2">“{searchQuery}” 검색 결과</h2>
|
||||
<p className="text-neutral-400 mb-8 text-sm">입력하신 검색어에 대한 플랫폼별 결과입니다.</p>
|
||||
|
||||
{isSearching ? (
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { SkipForward, SkipBack, Volume2, VolumeX, Pause, Play } from "lucide-react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import type { DiscordServer, Track } from "@/types/music";
|
||||
|
||||
interface PlayerBarProps {
|
||||
selectedServer: any;
|
||||
selectedServer: DiscordServer | null;
|
||||
}
|
||||
|
||||
export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
const { data: session } = useSession();
|
||||
|
||||
// 재생 상태 관리
|
||||
const [track, setTrack] = useState<any>(null);
|
||||
const [track, setTrack] = useState<Track | null>(null);
|
||||
const [botPlayer, setBotPlayer] = useState<boolean>(false);
|
||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||
const [isPaused, setIsPaused] = useState<boolean>(false);
|
||||
@@ -28,7 +29,7 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
const fetchNowPlaying = useCallback(async () => {
|
||||
if (!selectedServer) return;
|
||||
|
||||
const userId = (session?.user as any)?.id;
|
||||
const userId = session?.user?.id;
|
||||
if (!userId) return;
|
||||
|
||||
try {
|
||||
@@ -43,12 +44,12 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok && data.success && data.track) {
|
||||
setBotPlayer(data.botPlayer);
|
||||
setIsPlaying(data.isPlaying);
|
||||
setIsPaused(data.isPaused);
|
||||
setTrack(data.track);
|
||||
setDuration(data.track.info.length || 0);
|
||||
setVolume(data.volume ?? 50);
|
||||
setBotPlayer(Boolean(data.botPlayer));
|
||||
setIsPlaying(Boolean(data.isPlaying));
|
||||
setIsPaused(Boolean(data.isPaused));
|
||||
setTrack(data.track as Track);
|
||||
setDuration(Number(data.track?.info?.length ?? 0) || 0);
|
||||
setVolume(typeof data.volume === "number" ? data.volume : 50);
|
||||
// 드래그 중이 아닐 때만 서버 시간으로 동기화 (안 그러면 드래그할 때 튐)
|
||||
if (!isDragging.current) {
|
||||
setPosition(data.position || 0);
|
||||
@@ -67,42 +68,54 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
// 2. 초기 로드 및 SSE 실시간 업데이트 수신
|
||||
useEffect(() => {
|
||||
if (!selectedServer) return;
|
||||
// 서버 선택 시 1회 즉시 동기화 — 의도적 패턴.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
fetchNowPlaying();
|
||||
|
||||
// 봇에서 "곡 변경", "일시정지" 등의 이벤트가 발생하면 새로고침하라는 신호
|
||||
const eventSource = new EventSource(`/api/player/events?serverId=${selectedServer.id}`);
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === "player_update") {
|
||||
if (data?.type === "player_update") {
|
||||
fetchNowPlaying();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("SSE JSON 파싱 실패:", err);
|
||||
}
|
||||
};
|
||||
eventSource.onerror = (error) => {
|
||||
console.error("Player SSE 연결 오류:", error);
|
||||
eventSource.close();
|
||||
};
|
||||
return () => eventSource.close();
|
||||
}, [selectedServer, fetchNowPlaying]);
|
||||
|
||||
// 3. 🌟 로컬 1초 타이머 & 10초 서버 동기화 통합 (재생 중일 때만 작동!)
|
||||
// isPaused 상태를 ref 로 들고 있어서, interval 콜백이 항상 최신 값을 읽도록 처리.
|
||||
const isPausedRef = useRef(isPaused);
|
||||
useEffect(() => {
|
||||
let localInterval: NodeJS.Timeout;
|
||||
let syncInterval: NodeJS.Timeout;
|
||||
isPausedRef.current = isPaused;
|
||||
}, [isPaused]);
|
||||
|
||||
// 노래가 재생 중이고, 유저가 재생바를 잡고 있지 않을 때만 타이머들을 가동합니다.
|
||||
if (isPlaying && !isDragging.current) {
|
||||
useEffect(() => {
|
||||
if (!isPlaying) return;
|
||||
|
||||
// ① 1초마다 프론트엔드 단독으로 시계 굴리기 (부드러운 애니메이션용)
|
||||
localInterval = setInterval(() => {
|
||||
if (!isPaused) setPosition((prev) => {
|
||||
const localInterval = setInterval(() => {
|
||||
if (isPausedRef.current || isDragging.current) return;
|
||||
setPosition((prev) => {
|
||||
if (prev >= duration) return duration;
|
||||
return prev + 1000;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
// ② 10초마다 진짜 시간 서버에 물어보기 (오차 교정용)
|
||||
syncInterval = setInterval(() => {
|
||||
if (!isPaused) fetchNowPlaying();
|
||||
const syncInterval = setInterval(() => {
|
||||
if (isPausedRef.current || isDragging.current) return;
|
||||
fetchNowPlaying();
|
||||
}, 10000);
|
||||
|
||||
}
|
||||
|
||||
// 일시정지되거나 컴포넌트가 꺼지면 두 타이머 모두 깔끔하게 청소합니다.
|
||||
return () => {
|
||||
clearInterval(localInterval);
|
||||
@@ -117,9 +130,11 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
const handleTogglePause = async () => {
|
||||
if (!selectedServer || !track) return;
|
||||
if (!isPlaying) return;
|
||||
const userId = (session?.user as any)?.id;
|
||||
const userId = session?.user?.id;
|
||||
if (!userId) return;
|
||||
const nextPaused = !isPaused;
|
||||
// UI 즉각 반영 (Optimistic UI)
|
||||
setIsPaused(!isPaused);
|
||||
setIsPaused(nextPaused);
|
||||
try {
|
||||
const res = await fetch('/api/player/pause', {
|
||||
method: 'POST',
|
||||
@@ -127,27 +142,28 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
body: JSON.stringify({
|
||||
serverId: selectedServer.id,
|
||||
userId: userId,
|
||||
isPaused: String(isPaused),
|
||||
isPaused: nextPaused, // boolean 그대로 전송
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
if (data.isPaused?.trim().toLocaleLowerCase() === "true") {
|
||||
setIsPaused(true);
|
||||
// 봇이 실제로 적용된 paused 상태를 돌려줌. 없으면 낙관적 값 유지.
|
||||
if (typeof data.paused === "boolean") setIsPaused(data.paused);
|
||||
} else {
|
||||
setIsPaused(false);
|
||||
}
|
||||
// 실패 시 롤백
|
||||
setIsPaused(!nextPaused);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("일시정지 에러:", error);
|
||||
setIsPaused(!isPaused); // 실패 시 롤백
|
||||
setIsPaused(!nextPaused); // 실패 시 롤백
|
||||
}
|
||||
};
|
||||
|
||||
// 다음 곡 스킵 (player_skip)
|
||||
const handleSkip = async () => {
|
||||
if (!selectedServer || !track) return;
|
||||
const userId = (session?.user as any)?.id;
|
||||
const userId = session?.user?.id;
|
||||
if (!userId) return;
|
||||
try {
|
||||
await fetch('/api/player/skip', {
|
||||
method: 'POST',
|
||||
@@ -167,7 +183,8 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
// 🌟 [수정됨] 마우스 이벤트와 터치 이벤트를 모두 허용하도록 타입 변경
|
||||
const handleSeekEnd = async (e: React.MouseEvent<HTMLInputElement> | React.TouchEvent<HTMLInputElement>) => {
|
||||
if (!selectedServer || !track) return;
|
||||
const userId = (session?.user as any)?.id;
|
||||
const userId = session?.user?.id;
|
||||
if (!userId) return;
|
||||
isDragging.current = false;
|
||||
|
||||
// 🌟 [수정됨] e.target 대신 e.currentTarget을 사용해야 타입 에러가 나지 않습니다.
|
||||
@@ -198,7 +215,8 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
const handleVolumeEnd = async (e: React.MouseEvent<HTMLInputElement> | React.TouchEvent<HTMLInputElement>) => {
|
||||
setIsVolumeDragging(false);
|
||||
if (!selectedServer) return;
|
||||
const userId = (session?.user as any)?.id;
|
||||
const userId = session?.user?.id;
|
||||
if (!userId) return;
|
||||
const finalVolume = Number(e.currentTarget.value);
|
||||
setVolume(finalVolume); // UI 즉시 반영
|
||||
|
||||
|
||||
@@ -3,9 +3,10 @@ import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { Trash2, GripVertical, Music } from "lucide-react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useToast } from "@/components/ToastProvider";
|
||||
import type { DiscordServer, Track } from "@/types/music";
|
||||
|
||||
interface QueueSidebarProps {
|
||||
selectedServer: any;
|
||||
selectedServer: DiscordServer | null;
|
||||
}
|
||||
|
||||
export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
|
||||
@@ -14,8 +15,7 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
|
||||
// 👇 [추가] 토스트 사용 준비 완료!
|
||||
const { showToast } = useToast();
|
||||
|
||||
const [queue, setQueue] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [queue, setQueue] = useState<Track[]>([]);
|
||||
|
||||
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
@@ -26,10 +26,9 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
|
||||
const fetchQueue = useCallback(async () => {
|
||||
if (!selectedServer) return;
|
||||
|
||||
const userId = (session?.user as any)?.id;
|
||||
const userId = session?.user?.id;
|
||||
if (!userId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/queue/list', {
|
||||
method: 'POST',
|
||||
@@ -41,30 +40,34 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success && Array.isArray(data.queue)) {
|
||||
setQueue(data.queue);
|
||||
setQueue(data.queue as Track[]);
|
||||
} else {
|
||||
setQueue([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("큐 불러오기 실패:", error);
|
||||
setQueue([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [selectedServer, session]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "loading" || !selectedServer) return;
|
||||
|
||||
// 서버 선택 시 1회 즉시 동기화 — 의도적 패턴.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
fetchQueue();
|
||||
|
||||
const eventSource = new EventSource(`/api/queue/events?serverId=${selectedServer.id}`);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === "queue_update") {
|
||||
if (data?.type === "queue_update") {
|
||||
fetchQueue();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("SSE JSON 파싱 실패:", err);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
@@ -106,7 +109,7 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
|
||||
}
|
||||
|
||||
// --- 여기서부터는 진짜 순서가 바뀌었을 때만 실행됩니다 ---
|
||||
const userId = (session?.user as any)?.id;
|
||||
const userId = session?.user?.id;
|
||||
|
||||
// 3. 화면 즉시 업데이트
|
||||
const newQueue = [...queue];
|
||||
@@ -139,7 +142,7 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
|
||||
|
||||
const handleDelete = async (indexToRemove: number) => {
|
||||
if (!selectedServer) return;
|
||||
const userId = (session?.user as any)?.id;
|
||||
const userId = session?.user?.id;
|
||||
|
||||
const newQueue = queue.filter((_, index) => index !== indexToRemove);
|
||||
setQueue(newQueue);
|
||||
|
||||
@@ -1,28 +1,43 @@
|
||||
import colors from "colors/safe";
|
||||
|
||||
// Asia/Seoul(UTC+9) 타임스탬프. ISO 포맷에서 안전하게 추출.
|
||||
export const Timestamp = () => {
|
||||
const Now = new Date();
|
||||
Now.setHours(Now.getHours() + 9);
|
||||
return Now.toISOString().replace('T', ' ').substring(0, 19).slice(2);
|
||||
}
|
||||
const now = new Date(Date.now() + 9 * 60 * 60 * 1000);
|
||||
// YYYY-MM-DDTHH:mm:ss.sssZ 에서 YY-MM-DD HH:mm:ss
|
||||
const iso = now.toISOString();
|
||||
const date = iso.slice(2, 10); // YY-MM-DD
|
||||
const time = iso.slice(11, 19); // HH:mm:ss
|
||||
return `${date} ${time}`;
|
||||
};
|
||||
|
||||
type logType = "log" | "info" | "warn" | "error" | "debug" | "ready" | "slash";
|
||||
type LogType = "log" | "info" | "warn" | "error" | "debug" | "ready";
|
||||
|
||||
const log = (content: string, type: logType) => {
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
|
||||
const write = (label: string, content: string, useStderr: boolean) => {
|
||||
const timestamp = colors.white(`[${Timestamp()}]`);
|
||||
const line = `${label} ${timestamp} ${content}`;
|
||||
if (useStderr) console.error(line);
|
||||
else console.log(line);
|
||||
};
|
||||
|
||||
const log = (content: string, type: LogType) => {
|
||||
switch (type) {
|
||||
case "log":
|
||||
return console.log(`${colors.gray("[LOG]")} ${timestamp} ${content}`);
|
||||
// 일반 디버그성 로그는 프로덕션에서 숨김
|
||||
if (isProd) return;
|
||||
return write(colors.gray("[LOG]"), content, false);
|
||||
case "info":
|
||||
return console.log(`${colors.cyan("[INFO]")} ${timestamp} ${content}`);
|
||||
return write(colors.cyan("[INFO]"), content, false);
|
||||
case "warn":
|
||||
return console.log(`${colors.yellow("[WARN]")} ${timestamp} ${content}`);
|
||||
return write(colors.yellow("[WARN]"), content, true);
|
||||
case "error":
|
||||
return console.log(`${colors.red("[ERROR]")} ${timestamp} ${content}`);
|
||||
return write(colors.red("[ERROR]"), content, true);
|
||||
case "debug":
|
||||
return console.log(`${colors.magenta("[DEBUG]")} ${timestamp} ${content}`);
|
||||
if (isProd) return;
|
||||
return write(colors.magenta("[DEBUG]"), content, false);
|
||||
case "ready":
|
||||
return console.log(`${colors.green("[READY]")} ${timestamp} ${content}`);
|
||||
return write(colors.green("[READY]"), content, false);
|
||||
default:
|
||||
throw new TypeError("Logger 타입이 올바르지 않습니다.");
|
||||
}
|
||||
@@ -34,5 +49,5 @@ export const Logger = {
|
||||
error: (content: string) => log(content, "error"),
|
||||
debug: (content: string) => log(content, "debug"),
|
||||
info: (content: string) => log(content, "info"),
|
||||
ready: (content: string) => log(content, "ready")
|
||||
}
|
||||
ready: (content: string) => log(content, "ready"),
|
||||
};
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { Redis as RedisClass } from "ioredis";
|
||||
import { Logger } from "@/lib/Logger";
|
||||
|
||||
// .env.local 파일에서 설정한 IP를 가져옵니다. (기본값으로 Proxmox IP 세팅)
|
||||
const REDIS_HOST = process.env.REDIS_HOST || "192.168.10.7";
|
||||
// Redis 호스트는 환경변수에서 가져옵니다. 미설정 시 부팅 시점에 명확하게 에러를 던집니다.
|
||||
const REDIS_HOST = process.env.REDIS_HOST?.trim();
|
||||
const REDIS_PORT = Number(process.env.REDIS_PORT?.trim() ?? "6379");
|
||||
|
||||
if (!REDIS_HOST) {
|
||||
throw new Error("[Redis] REDIS_HOST 환경변수가 설정되지 않았습니다.");
|
||||
}
|
||||
if (!Number.isFinite(REDIS_PORT) || REDIS_PORT <= 0) {
|
||||
throw new Error(`[Redis] REDIS_PORT 값이 올바르지 않습니다: ${process.env.REDIS_PORT}`);
|
||||
}
|
||||
|
||||
// Next.js 개발 환경(HMR)에서 커넥션이 무한 증식하는 것을 막기 위한 글로벌 객체 선언
|
||||
const globalForRedis = global as unknown as {
|
||||
@@ -9,16 +18,24 @@ const globalForRedis = global as unknown as {
|
||||
};
|
||||
|
||||
// 이미 연결된 객체가 있으면 그걸 쓰고, 없으면 새로 연결합니다.
|
||||
export const Redis = globalForRedis.redisClient ?? new RedisClass({ host: REDIS_HOST, port: 6379 });
|
||||
export const Redis = globalForRedis.redisClient ?? new RedisClass({
|
||||
host: REDIS_HOST,
|
||||
port: REDIS_PORT,
|
||||
lazyConnect: false,
|
||||
maxRetriesPerRequest: 3,
|
||||
});
|
||||
|
||||
// 프로덕션(배포) 모드가 아닐 때만 글로벌 변수에 저장해 둡니다.
|
||||
if (process.env.NODE_ENV !== "production") globalForRedis.redisClient = Redis;
|
||||
|
||||
// 연결 성공 시 로그 한 번만 찍기
|
||||
let connectLogged = false;
|
||||
Redis.on("connect", () => {
|
||||
console.log("🟢 [Next.js] Proxmox Redis(우체국) 연결 완료!");
|
||||
if (connectLogged) return;
|
||||
connectLogged = true;
|
||||
Logger.ready(`Redis 연결 완료 (${REDIS_HOST}:${REDIS_PORT})`);
|
||||
});
|
||||
|
||||
Redis.on("error", (err) => {
|
||||
console.error("❌ [Next.js] Redis 연결 에러:", err);
|
||||
Logger.error(`Redis 연결 에러: ${err instanceof Error ? err.message : String(err)}`);
|
||||
});
|
||||
163
page/src/lib/api.ts
Normal file
163
page/src/lib/api.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getServerSession, type Session } from "next-auth";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Redis } from "@/lib/Redis";
|
||||
import { Logger } from "@/lib/Logger";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
|
||||
// ========== 공용 응답 스키마 ==========
|
||||
// 모든 API는 { success: boolean, ... } 형태로 응답한다.
|
||||
// 실패시 { success: false, error: string } 보장.
|
||||
|
||||
export const errorResponse = (message: string, status = 400) =>
|
||||
NextResponse.json({ success: false, error: message }, { status });
|
||||
|
||||
// ========== 세션 가드 ==========
|
||||
// 세션이 없으면 401 NextResponse를 던지고, 있으면 세션을 돌려준다.
|
||||
export async function requireSession(): Promise<
|
||||
{ ok: true; session: Session } | { ok: false; response: NextResponse }
|
||||
> {
|
||||
const session = (await getServerSession(authOptions)) as Session | null;
|
||||
if (!session?.user?.id) {
|
||||
return { ok: false, response: errorResponse("인증되지 않았습니다.", 401) };
|
||||
}
|
||||
return { ok: true, session };
|
||||
}
|
||||
|
||||
// ========== 봇 RPC 헬퍼 ==========
|
||||
// site → bot: Redis Pub/Sub 으로 명령 전송
|
||||
// bot → site: Redis SET 으로 결과 저장 (resultKey)
|
||||
// 사이트는 resultKey 를 short polling 으로 확인.
|
||||
|
||||
export interface BotRpcOptions {
|
||||
/** Redis 결과 키 prefix (e.g. "player:now") */
|
||||
channel: string;
|
||||
/** 봇으로 보낼 페이로드. requestId는 자동 주입됨. */
|
||||
payload: Record<string, unknown>;
|
||||
/** 폴링 총 타임아웃 (ms). 기본 3000. */
|
||||
timeoutMs?: number;
|
||||
/** 폴링 간격 (ms). 기본 100ms 시작 → 최대 400ms로 백오프. */
|
||||
pollIntervalMs?: number;
|
||||
/** Redis 결과 키 만료(초). 기본 5초 — 클라이언트 타임아웃 후에도 키가 남아있는 것을 방지. */
|
||||
resultTtlSec?: number;
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
|
||||
export async function botRpc(
|
||||
opts: BotRpcOptions,
|
||||
): Promise<{ status: number; body: Record<string, unknown> }> {
|
||||
const {
|
||||
channel,
|
||||
payload,
|
||||
timeoutMs = 3000,
|
||||
pollIntervalMs = 100,
|
||||
resultTtlSec = 5,
|
||||
} = opts;
|
||||
|
||||
// CSPRNG 기반 requestId — Date.now() + Math.random() 충돌 가능성 제거
|
||||
const requestId = `req:${randomUUID()}`;
|
||||
const resultKey = `${channel}:${requestId}`;
|
||||
|
||||
// 봇에게 명령 전송 (action 필드는 호출부에서 payload 에 포함)
|
||||
await Redis.publish(
|
||||
"site-bot",
|
||||
JSON.stringify({ ...payload, requestId }),
|
||||
);
|
||||
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let interval = pollIntervalMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(interval);
|
||||
interval = Math.min(interval * 2, 400);
|
||||
|
||||
const reply = await Redis.get(resultKey);
|
||||
if (!reply) continue;
|
||||
|
||||
// 읽은 즉시 정리 (TTL 도 보험으로 깔려있음)
|
||||
await Redis.del(resultKey);
|
||||
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(reply);
|
||||
} catch (err) {
|
||||
Logger.error(`[botRpc:${channel}] 봇 응답 파싱 실패: ${String(err)}`);
|
||||
return {
|
||||
status: 502,
|
||||
body: { success: false, error: "봇 응답을 해석할 수 없습니다." },
|
||||
};
|
||||
}
|
||||
// 봇이 success 필드를 명시한 경우만 false로 평가. 없으면 데이터 응답으로 간주(200).
|
||||
const hasSuccessField = "success" in parsed;
|
||||
const ok = !hasSuccessField || parsed.success === true;
|
||||
return { status: ok ? 200 : 400, body: parsed };
|
||||
}
|
||||
|
||||
// 타임아웃 — 봇이 늦게 응답해도 메모리에 쌓이지 않도록 만료 설정
|
||||
// (resultKey 가 아직 없을 수 있으므로 expire 가 0 을 반환할 수 있음, 무해함)
|
||||
try {
|
||||
await Redis.expire(resultKey, resultTtlSec);
|
||||
} catch {
|
||||
// 무시: 정리 실패는 치명적 아님
|
||||
}
|
||||
|
||||
return {
|
||||
status: 504,
|
||||
body: { success: false, error: "봇이 응답하지 않거나 오프라인 상태입니다." },
|
||||
};
|
||||
}
|
||||
|
||||
// ========== POST 본문 파싱 ==========
|
||||
export async function readJsonBody<T = unknown>(
|
||||
request: Request,
|
||||
): Promise<{ ok: true; data: T } | { ok: false; response: NextResponse }> {
|
||||
try {
|
||||
const data = (await request.json()) as T;
|
||||
return { ok: true, data };
|
||||
} catch {
|
||||
return { ok: false, response: errorResponse("요청 본문이 올바른 JSON이 아닙니다.", 400) };
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 필수 필드 검증 ==========
|
||||
export function requireString(
|
||||
value: unknown,
|
||||
field: string,
|
||||
): { ok: true; value: string } | { ok: false; response: NextResponse } {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
return { ok: false, response: errorResponse(`${field} 정보가 필요합니다.`, 400) };
|
||||
}
|
||||
return { ok: true, value: value.trim() };
|
||||
}
|
||||
|
||||
export function requireNumber(
|
||||
value: unknown,
|
||||
field: string,
|
||||
opts?: { min?: number; max?: number; integer?: boolean },
|
||||
): { ok: true; value: number } | { ok: false; response: NextResponse } {
|
||||
const n = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(n)) {
|
||||
return { ok: false, response: errorResponse(`${field} 값이 올바르지 않습니다.`, 400) };
|
||||
}
|
||||
if (opts?.integer && !Number.isInteger(n)) {
|
||||
return { ok: false, response: errorResponse(`${field} 는 정수여야 합니다.`, 400) };
|
||||
}
|
||||
if (opts?.min !== undefined && n < opts.min) {
|
||||
return { ok: false, response: errorResponse(`${field} 가 너무 작습니다.`, 400) };
|
||||
}
|
||||
if (opts?.max !== undefined && n > opts.max) {
|
||||
return { ok: false, response: errorResponse(`${field} 가 너무 큽니다.`, 400) };
|
||||
}
|
||||
return { ok: true, value: n };
|
||||
}
|
||||
|
||||
export function requireBoolean(
|
||||
value: unknown,
|
||||
field: string,
|
||||
): { ok: true; value: boolean } | { ok: false; response: NextResponse } {
|
||||
if (typeof value !== "boolean") {
|
||||
return { ok: false, response: errorResponse(`${field} 는 boolean 이어야 합니다.`, 400) };
|
||||
}
|
||||
return { ok: true, value };
|
||||
}
|
||||
129
page/src/lib/sse.ts
Normal file
129
page/src/lib/sse.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { Redis } from "@/lib/Redis";
|
||||
import { Logger } from "@/lib/Logger";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
|
||||
interface BotEvent {
|
||||
event?: unknown;
|
||||
guildId?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface BotEventStreamOptions {
|
||||
/** bot 이 발행하는 event 이름 (e.g. "player_update", "queue_update"). */
|
||||
botEventName: string;
|
||||
/** 클라이언트로 보낼 SSE type. 기본: botEventName 과 동일. */
|
||||
clientEventType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 봇이 publish 하는 "bot-site" 채널을 구독해서 SSE 로 흘려보내는 공용 핸들러.
|
||||
* - 인증 가드: 세션 없으면 401
|
||||
* - serverId 검증
|
||||
* - JSON.parse 안전 처리
|
||||
* - subscriber error / 클라이언트 abort 모두에서 깔끔히 정리
|
||||
* - keepalive ping (30초)
|
||||
*/
|
||||
export async function botEventStream(req: NextRequest, opts: BotEventStreamOptions): Promise<Response> {
|
||||
const clientEventType = opts.clientEventType ?? opts.botEventName;
|
||||
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
return new Response("인증되지 않았습니다.", { status: 401 });
|
||||
}
|
||||
|
||||
const serverId = req.nextUrl.searchParams.get("serverId")?.trim();
|
||||
if (!serverId) {
|
||||
return new Response("Missing serverId", { status: 400 });
|
||||
}
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const subscriber = Redis.duplicate();
|
||||
let closed = false;
|
||||
const timers: NodeJS.Timeout[] = [];
|
||||
|
||||
const cleanup = async () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (const t of timers) clearInterval(t);
|
||||
try {
|
||||
await subscriber.unsubscribe("bot-site");
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
try {
|
||||
await subscriber.quit();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
/* 이미 닫혔을 수 있음 */
|
||||
}
|
||||
};
|
||||
|
||||
subscriber.on("error", async (err) => {
|
||||
Logger.error(`[SSE:${opts.botEventName}] subscriber error: ${err.message}`);
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
try {
|
||||
await subscriber.subscribe("bot-site");
|
||||
} catch (err) {
|
||||
Logger.error(`[SSE:${opts.botEventName}] subscribe 실패: ${String(err)}`);
|
||||
await cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
subscriber.on("message", (channel, message) => {
|
||||
if (channel !== "bot-site" || closed) return;
|
||||
let data: BotEvent;
|
||||
try {
|
||||
data = JSON.parse(message) as BotEvent;
|
||||
} catch (err) {
|
||||
Logger.warn(`[SSE:${opts.botEventName}] 잘못된 JSON: ${String(err)}`);
|
||||
return;
|
||||
}
|
||||
if (data.guildId !== serverId) return;
|
||||
if (data.event !== opts.botEventName) return;
|
||||
try {
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(`data: ${JSON.stringify({ type: clientEventType })}\n\n`),
|
||||
);
|
||||
} catch (err) {
|
||||
Logger.warn(`[SSE:${opts.botEventName}] enqueue 실패: ${String(err)}`);
|
||||
void cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
// 30초마다 keep-alive 코멘트 전송 (프록시 timeout 방지)
|
||||
timers.push(
|
||||
setInterval(() => {
|
||||
if (closed) return;
|
||||
try {
|
||||
controller.enqueue(new TextEncoder().encode(`: keep-alive\n\n`));
|
||||
} catch {
|
||||
void cleanup();
|
||||
}
|
||||
}, 30000),
|
||||
);
|
||||
|
||||
// 클라이언트가 연결을 끊으면 정리
|
||||
req.signal.addEventListener("abort", () => {
|
||||
void cleanup();
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
});
|
||||
}
|
||||
42
page/src/types/music.ts
Normal file
42
page/src/types/music.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// 공용 도메인 타입 — 봇/Discord/Lavalink 데이터 표현.
|
||||
|
||||
export interface DiscordServer {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
owner: boolean;
|
||||
permissions: string;
|
||||
}
|
||||
|
||||
export interface TrackInfo {
|
||||
identifier?: string;
|
||||
title?: string;
|
||||
author?: string;
|
||||
length?: number;
|
||||
artworkUrl?: string;
|
||||
uri?: string;
|
||||
}
|
||||
|
||||
export interface Track {
|
||||
encoded?: string;
|
||||
info?: TrackInfo;
|
||||
// 큐 항목에는 종종 추가 메타데이터(요청자 등)가 붙음.
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// 검색 결과(곡 카드) 공용 타입 — Spotify/YT Music/YT Video 모두 매핑됨.
|
||||
export interface SearchTrack {
|
||||
videoId?: string;
|
||||
id?: string;
|
||||
url?: string;
|
||||
title?: string;
|
||||
artist?: string;
|
||||
thumbnail?: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export interface SearchResults {
|
||||
spotify: SearchTrack[];
|
||||
youtubeMusic: SearchTrack[];
|
||||
youtubeVideo: SearchTrack[];
|
||||
}
|
||||
21
page/src/types/next-auth.d.ts
vendored
Normal file
21
page/src/types/next-auth.d.ts
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
import "next-auth";
|
||||
import "next-auth/jwt";
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
accessToken?: string;
|
||||
user: {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
image?: string | null;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
declare module "next-auth/jwt" {
|
||||
interface JWT {
|
||||
id?: string;
|
||||
accessToken?: string;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"target": "ES2020",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
Reference in New Issue
Block a user