Compare commits

6 Commits
master ... dev

Author SHA1 Message Date
tkrmagid
e476e23d37 Merge master into dev: 대소문자 수정 통합
origin/master의 c86bc25 (bot/src/utils/shuffle.ts → Shuffle.ts 대소문자 수정)을
dev에 머지. dev에는 이미 26393fe에서 같은 rename + import 경로 갱신이
들어가 있어서 파일명/경로 충돌은 그대로 dev쪽 유지.

Shuffle.ts 내용 충돌은 dev쪽 (d0dcdb1 코드 품질 개선의 Fisher-Yates 1-pass
구현) 유지. master는 d0dcdb1 이전이라 fshuffle 다중 셔플 구버전이었음.
2026-05-16 02:51:04 +09:00
tkrmagid
a4b3d40efa bot: 음성 WS 끊김 진단 로깅 + 자가 회복
- closed 이벤트에서 code/reason/byRemote 로깅 (4006/4014/4015 등 원인 식별)
- 5초 후 봇이 보이스 채널에 남아있으면 player를 새로 만들어 현재 곡을
  position 그대로 이어서 재생 (일시정지/볼륨 상태도 복원)
- 재접속 실패 시에만 기존처럼 player 정리
- 라우터/네트워크 일시 끊김(4006 세션 만료 등) 시 사용자 체감 끊김 최소화
2026-05-14 11:27:56 +09:00
26393fec2f bot/utils/shuffle.ts → Shuffle.ts 대문자 통일
리눅스 빌드 환경에서 대소문자 잔존 파일과 충돌(TS1261)을 막기 위해
파일명을 PascalCase 로 통일하고 import 경로도 동일하게 수정.
2026-04-28 16:09:29 +09:00
b670a61192 page 전체 코드 품질/보안 개선 및 봇 RPC 검증 정합
[보안/인증]
- 모든 player/queue API 라우트에 세션 가드 추가 (이전: /api/servers 만 보호)
- NextAuth 환경변수 부팅 시점 검증, NEXTAUTH_SECRET 명시
- next.config.ts CSP/보안 헤더 추가, 잘못된 allowedDevOrigins 제거
- Redis 호스트 하드코딩 IP 제거(필수 env 로 강제)

[안정성]
- 봇 RPC 패턴(@/lib/api) 공용화: crypto.randomUUID requestId, JSON.parse 안전, EXPIRE 자동, 폴링 백오프
- SSE(@/lib/sse) 공용화: subscriber error 처리, JSON.parse 안전, 30초 keep-alive, abort/에러 정리
- pause API 양 끝(boolean) 정상화: 프론트 String() 캐스트 + 백엔드 .trim().toLowerCase() 비교 제거
- 봇 RedisClient: isPaused/index/seek/volume falsy 거부 → typeof 검사로 교체(0/false 정상 허용)

[타입/품질]
- next-auth 모듈 보강 → session.user.id, session.accessToken 타입 안전
- DiscordServer/Track/SearchTrack 공용 타입 도입, 컴포넌트 any 제거
- BigInt permissions 안전 검증(타입 가드)
- Logger: NODE_ENV 게이트, error → stderr, ISO 기반 안전 timestamp
- tsconfig target → ES2020 (BigInt 리터럴)

[취약점]
- next 16.2.2 → 16.2.4 (DoS/postcss XSS 패치)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 14:56:55 +09:00
e5f3b87b1d package-lock.json 정리 (axios 제거 및 npm audit fix)
- 이전 커밋에서 제거한 axios가 lock 파일에 남아있던 것 정리
- npm audit fix로 transitive 의존성 보안 패치 적용 완료
- 보안 취약점 0건 (이전: high 2, moderate 1)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 02:01:14 +09:00
d0dcdb1563 bot 전체 코드 품질 개선 및 버그 수정
- GuildPlayer: 타이머 레이스 컨디션 수정, 모든 타이머 정리 로직 통합 (clearAllTimers)
- GuildPlayer: 이벤트 핸들러에 try-catch 추가 (end, exception, stuck)
- GuildPlayer: start 이벤트에서 endTimer 정리, autoPlay tracks 길이 검증 추가
- RedisClient: player_seek, player_volume에 누락된 return ���가
- RedisClient: queue_remove 인덱스 검증 주석 명확화
- Handler: runCommand에 try-catch 추가하여 에러 시 사용자에게 응답
- Channel: getGuildById에 누락된 await 추가, getMemberById/getVoiceChannelById 안전한 에러 처리
- Command.d.ts: 잘못된 타입 ChatInputChatInputCommandInteraction → ChatInputCommandInteraction 수정
- join.ts: 채널 멘션 닫는 괄호 누락 수정
- shuffle.ts: 제네릭 타입 적용, 불필요한 5회 반복 제거
- import 경로 대소문자 수정 (Shuffle → shuffle) - Linux 호환
- YoutubeMusic/Spotify: 하드코딩된 IP를 환경변수로 분리
- console.log/error → Logger 통일 (YoutubeMusic, Button, channel)
- interactionCreate: 전체 try-catch 추가, silent catch에 로깅 추가
- Database: schema 경로 __dirname 기반으로 수정, 컬럼 화이트리스트 추가
- 사용하지 않는 코드 정리 (axios 의존성, 주석처리된 user 관련 코드)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-26 23:13:16 +09:00
50 changed files with 1287 additions and 1244 deletions

6
bot/db/db.d.ts vendored
View File

@@ -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
View File

@@ -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",

View File

@@ -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",

View File

@@ -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)));
}
}

View File

@@ -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);
}

View File

@@ -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(() => {});
}
}
}

View File

@@ -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보다 크거나 같아야합니다." }));

View File

@@ -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({

View File

@@ -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({

View File

@@ -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)}`);
}
}

View File

@@ -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> => {}

View File

@@ -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>;

View File

@@ -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(),

View File

@@ -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;
// }
// },
// },
};

View File

@@ -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;
}

View File

@@ -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>();

View File

@@ -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 [];
}
},

View File

@@ -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;
});
}

View File

@@ -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; }> => {

View File

@@ -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
View File

@@ -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": {

View File

@@ -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"
}

View File

@@ -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;
},
},

View File

@@ -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" });
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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" });
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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 });
}

View File

@@ -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 });
}
}

View File

@@ -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");
};

View File

@@ -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>
);
}

View File

@@ -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) {

View File

@@ -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">&ldquo;{searchQuery}&rdquo; </h2>
<p className="text-neutral-400 mb-8 text-sm"> .</p>
{isSearching ? (

View File

@@ -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 즉시 반영

View File

@@ -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);

View File

@@ -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"),
};

View File

@@ -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
View 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
View 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
View 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
View 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;
}
}

View File

@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "ES2017",
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,