지금까지 내용 커밋

This commit is contained in:
2026-04-08 12:59:45 +09:00
commit b0dae31cb9
68 changed files with 12083 additions and 0 deletions

6
bot/.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
__pycache__
*.pyc
.git
venv/
node_modules/
dist/

7
bot/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
*.env
dist/
*.db
src/test.ts
test/
ytdlp/

20
bot/Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM node:20-alpine
RUN apk add --no-cache \
ca-certificates \
git
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN mkdir -p dist
RUN npm run build
CMD ["npm", "run", "start"]

16
bot/db/db.d.ts vendored Normal file
View File

@@ -0,0 +1,16 @@
export interface GuildType {
id: string;
name: string;
channel_id: string;
msg_id: string;
options: {
recommend: boolean;
};
}
export type GuildRow = Omit<GuildType, "options"> & { options: string };
// export interface UserType {
// guild_id: string;
// id: string;
// name: string;
// }

24
bot/db/schema.sql Normal file
View File

@@ -0,0 +1,24 @@
-- 외래키 제약 조건 활성화
-- 기본적으로 SQLite는 외래키 검사 안함 그래서 켜줘야 함
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS guilds (
id TEXT PRIMARY KEY, -- 길드 ID (전역 유일값)
name TEXT NOT NULL, -- 길드 이름 (캐싱용)
channel_id TEXT NOT NULL, -- 채팅 ID
msg_id TEXT NOT NULL, -- 메세지 ID
options TEXT NOT NULL -- 옵션 JSON
);
-- CREATE TABLE IF NOT EXISTS users (
-- guild_id TEXT NOT NULL, -- 소속 길드 ID, guilds.id를 참조
-- id TEXT NOT NULL, -- 유저 ID
-- name TEXT NOT NULL, -- 유저 이름 (캐싱용)
-- -- 복합 기본키: 같은 길드 안에서 id는 중복 불가
-- PRIMARY KEY (guild_id, id),
-- -- 외래키 설정: guilds.id를 참조
-- -- 길드가 삭제되면 소속된 유저도 자동으로 삭제됨 (ON DELETE CASCADE)
-- FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE
-- );

View File

@@ -0,0 +1,5 @@
node 22.21.0
```
npx youtube-po-token-generator
```

BIN
bot/images/default_img.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 850 KiB

1453
bot/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
bot/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "music_bot_v2",
"version": "0.0.1",
"description": "discord music bot with lavalink",
"license": "ISC",
"author": "tkrmagid",
"type": "commonjs",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node .",
"dev": "ts-node src/index.ts",
"test": "ts-node src/test.ts"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^25.5.0",
"@types/spotify-api": "^0.0.26",
"ts-node": "^10.9.2",
"typescript": "^6.0.2"
},
"dependencies": {
"axios": "^1.14.0",
"better-sqlite3": "^12.8.0",
"colors": "^1.4.0",
"discord.js": "^14.25.1",
"dotenv": "^17.3.1",
"ioredis": "^5.10.1",
"shoukaku": "^4.3.0",
"undici": "^7.24.7"
}
}

522
bot/result.ts Normal file
View File

@@ -0,0 +1,522 @@
let result = [
{
"encoded": "QAABnwMADEhvbGQgbXkgaGFuZAACSVUAAAAAAAL6kAAWMEtWQ2xYeHNaRUtreVdSTlhlUkZyRQABADVodHRwczovL29wZW4uc3BvdGlmeS5jb20vdHJhY2svMEtWQ2xYeHNaRUtreVdSTlhlUkZyRQEAQGh0dHBzOi8vaS5zY2RuLmNvL2ltYWdlL2FiNjc2MTZkMDAwMGIyNzNmMWVmYjQ2N2FjNGM3NDg2MzBmZmQyMmYBAAxLUkEzODEwMDE1NjEAB3Nwb3RpZnkBABdNeSBMYXN0IExvdmUgT1NUIFBhcnQuNAEANWh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS9hbGJ1bS81OENCd005OFkzNTZ6RDVBVkdaa1pHAQA2aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FydGlzdC8zSHFTTE1BWjNnM2Q1cG9OYUk3R09VAQBAaHR0cHM6Ly9pLnNjZG4uY28vaW1hZ2UvYWI2NzYxNjEwMDAwZTVlYjc4OWYzODA0MmU1ZWY4OTExZmMzODI2YgAAAAAAAAAAAAA=",
"info": {
"identifier": "0KVClXxsZEKkyWRNXeRFrE",
"isSeekable": true,
"author": "IU",
"length": 195216,
"isStream": false,
"position": 0,
"title": "Hold my hand",
"uri": "https://open.spotify.com/track/0KVClXxsZEKkyWRNXeRFrE",
"sourceName": "spotify",
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b273f1efb467ac4c748630ffd22f",
"isrc": "KRA381001561"
},
"pluginInfo": {
"albumUrl": "https://open.spotify.com/album/58CBwM98Y356zD5AVGZkZG",
"albumName": "My Last Love OST Part.4",
"previewUrl": null,
"isPreview": false,
"artistUrl": "https://open.spotify.com/artist/3HqSLMAZ3g3d5poNaI7GOU",
"artistArtworkUrl": "https://i.scdn.co/image/ab6761610000e5eb789f38042e5ef8911fc3826b",
"isLocal": false
},
"userData": {}
},
{
"encoded": "QAABxQMAEeuCtCDshpDsnYQg7J6h7JWEAAVHaWxtZQAAAAAAAx1YABY1NjFtMHV4aVBqMUpRUG8weEQ0OHFwAAEANWh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS90cmFjay81NjFtMHV4aVBqMUpRUG8weEQ0OHFwAQBAaHR0cHM6Ly9pLnNjZG4uY28vaW1hZ2UvYWI2NzYxNmQwMDAwYjI3M2NmNGY3NDVhNThkMGU2OWNiNmY2OGZmZQEADEtSQTM0MTQwNTU2MQAHc3BvdGlmeQEANU1CQyDrgrTshpDsnYTsnqHslYQsIChPcmlnaW5hbCBUZWxldmlzaW9uIFNvdW5kdHJhY2spAQA1aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLzc0aEJ2RTBzNTRlQ0RmcVdNRFJRaHEBADZodHRwczovL29wZW4uc3BvdGlmeS5jb20vYXJ0aXN0LzU1alI0bUJxSXNMVUwwUzBTNUZkTGkBAEBodHRwczovL2kuc2Nkbi5jby9pbWFnZS9hYjY3NjE2ZDAwMDBiMjczYTM0MDY4Y2IyMTE0Mjc4ZWZiM2ExMTU3AAAAAAAAAAAAAA==",
"info": {
"identifier": "561m0uxiPj1JQPo0xD48qp",
"isSeekable": true,
"author": "Gilme",
"length": 204120,
"isStream": false,
"position": 0,
"title": "내 손을 잡아",
"uri": "https://open.spotify.com/track/561m0uxiPj1JQPo0xD48qp",
"sourceName": "spotify",
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b273cf4f745a58d0e69cb6f68ffe",
"isrc": "KRA341405561"
},
"pluginInfo": {
"albumUrl": "https://open.spotify.com/album/74hBvE0s54eCDfqWMDRQhq",
"albumName": "MBC 내손을잡아, (Original Television Soundtrack)",
"previewUrl": null,
"isPreview": false,
"artistUrl": "https://open.spotify.com/artist/55jR4mBqIsLUL0S0S5FdLi",
"artistArtworkUrl": "https://i.scdn.co/image/ab67616d0000b273a34068cb2114278efb3a1157",
"isLocal": false
},
"userData": {}
},
{
"encoded": "QAABygMACeyCrOueke2VtAAG7Iug7JygAAAAAAADvg8AFjIzVUlWZ0Z5eUVQUU4yUk1jSHUzYzkAAQA1aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLzIzVUlWZ0Z5eUVQUU4yUk1jSHUzYzkBAEBodHRwczovL2kuc2Nkbi5jby9pbWFnZS9hYjY3NjE2ZDAwMDBiMjczMDQ2ODdjMjljNWI4ZTBlNjc5ODc1NzQ2AQAMS1JBMzQxNDAyNjU3AAdzcG90aWZ5AQBBTUJDIOuCtOyGkOydhOyeoeyVhCwgUHQuIDEgKE9yaWdpbmFsIFRlbGV2aXNpb24gU291bmR0cmFjaykgUHQuIDEBADVodHRwczovL29wZW4uc3BvdGlmeS5jb20vYWxidW0vMVVpazNqV0tTSWdnNktISkRrRDBaagEANmh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS9hcnRpc3QvNWFFa3ZDZWRNM2FTMG11ZlY2cWtOSwEAQGh0dHBzOi8vaS5zY2RuLmNvL2ltYWdlL2FiNjc2MTZkMDAwMGIyNzMzNzEzMzFiZjA5ZDQ0NjQ5YTVlYzU2ODkAAAAAAAAAAAAA",
"info": {
"identifier": "23UIVgFyyEPQN2RMcHu3c9",
"isSeekable": true,
"author": "신유",
"length": 245263,
"isStream": false,
"position": 0,
"title": "사랑해",
"uri": "https://open.spotify.com/track/23UIVgFyyEPQN2RMcHu3c9",
"sourceName": "spotify",
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b27304687c29c5b8e0e679875746",
"isrc": "KRA341402657"
},
"pluginInfo": {
"albumUrl": "https://open.spotify.com/album/1Uik3jWKSIgg6KHJDkD0Zj",
"albumName": "MBC 내손을잡아, Pt. 1 (Original Television Soundtrack) Pt. 1",
"previewUrl": null,
"isPreview": false,
"artistUrl": "https://open.spotify.com/artist/5aEkvCedM3aS0mufV6qkNK",
"artistArtworkUrl": "https://i.scdn.co/image/ab67616d0000b273371331bf09d44649a5ec5689",
"isLocal": false
},
"userData": {}
},
{
"encoded": "QAAB1AMAE+yVhO2UhOqzoCDslYTtjIzshJwABuynhOyjvAAAAAAAA0WNABYzNUpyaUt1UU9tWVBlQTdQYWhHd3VLAAEANWh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS90cmFjay8zNUpyaUt1UU9tWVBlQTdQYWhHd3VLAQBAaHR0cHM6Ly9pLnNjZG4uY28vaW1hZ2UvYWI2NzYxNmQwMDAwYjI3MzA4NmRkMWZkZTY2NGU2MjM4OWY4ZmJiZgEADEtSQTM0MTQwMzI4OQAHc3BvdGlmeQEAQU1CQyDrgrTshpDsnYTsnqHslYQsIFB0LiAyIChPcmlnaW5hbCBUZWxldmlzaW9uIFNvdW5kdHJhY2spIFB0LiAyAQA1aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLzBIMllUNjI5aE56Y1pGTHg0cWNNMU8BADZodHRwczovL29wZW4uc3BvdGlmeS5jb20vYXJ0aXN0LzFqQ2lHZWVtZVZlR1BlOVlieENPdXYBAEBodHRwczovL2kuc2Nkbi5jby9pbWFnZS9hYjY3NjE2ZDAwMDBiMjczNGE2MGVjMzE0YmEwNDMzOGI1MWM3YzQzAAAAAAAAAAAAAA==",
"info": {
"identifier": "35JriKuQOmYPeA7PahGwuK",
"isSeekable": true,
"author": "진주",
"length": 214413,
"isStream": false,
"position": 0,
"title": "아프고 아파서",
"uri": "https://open.spotify.com/track/35JriKuQOmYPeA7PahGwuK",
"sourceName": "spotify",
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b273086dd1fde664e62389f8fbbf",
"isrc": "KRA341403289"
},
"pluginInfo": {
"albumUrl": "https://open.spotify.com/album/0H2YT629hNzcZFLx4qcM1O",
"albumName": "MBC 내손을잡아, Pt. 2 (Original Television Soundtrack) Pt. 2",
"previewUrl": null,
"isPreview": false,
"artistUrl": "https://open.spotify.com/artist/1jCiGeemeVeGPe9YbxCOuv",
"artistArtworkUrl": "https://i.scdn.co/image/ab67616d0000b2734a60ec314ba04338b51c7c43",
"isLocal": false
},
"userData": {}
},
{
"encoded": "QAABoAMAD1NwcmluZyBNZW1vcmllcwAITi5GbHlpbmcAAAAAAAL6zAAWNndpSDIyenVTa1RzY2s3eGpOdVFzeQABADVodHRwczovL29wZW4uc3BvdGlmeS5jb20vdHJhY2svNndpSDIyenVTa1RzY2s3eGpOdVFzeQEAQGh0dHBzOi8vaS5zY2RuLmNvL2ltYWdlL2FiNjc2MTZkMDAwMGIyNzNmYThhY2FkMzRlMjUwMWFjYzc0NjRkNTkBAAxLUkEzODE5MDE2ODcAB3Nwb3RpZnkBAA9TcHJpbmcgTWVtb3JpZXMBADVodHRwczovL29wZW4uc3BvdGlmeS5jb20vYWxidW0vNFowTzBZbGdabXZIbWNPR1RsR2d1SAEANmh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS9hcnRpc3QvMlptWGV4SUpBRDdQZ0FCcmowcVFSYgEAQGh0dHBzOi8vaS5zY2RuLmNvL2ltYWdlL2FiNjc2MTYxMDAwMGU1ZWJiMGM2NzNjMTcyNWVlY2Q3MDJiOTJjZjIAAAAAAAAAAAAA",
"info": {
"identifier": "6wiH22zuSkTsck7xjNuQsy",
"isSeekable": true,
"author": "N.Flying",
"length": 195276,
"isStream": false,
"position": 0,
"title": "Spring Memories",
"uri": "https://open.spotify.com/track/6wiH22zuSkTsck7xjNuQsy",
"sourceName": "spotify",
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b273fa8acad34e2501acc7464d59",
"isrc": "KRA381901687"
},
"pluginInfo": {
"albumUrl": "https://open.spotify.com/album/4Z0O0YlgZmvHmcOGTlGguH",
"albumName": "Spring Memories",
"previewUrl": null,
"isPreview": false,
"artistUrl": "https://open.spotify.com/artist/2ZmXexIJAD7PgABrj0qQRb",
"artistArtworkUrl": "https://i.scdn.co/image/ab6761610000e5ebb0c673c1725eecd702b92cf2",
"isLocal": false
},
"userData": {}
},
{
"encoded": "QAABzQMADOq4sOuLpOumsOuLpAAMS2ltIFlvbmcgSmluAAAAAAADJesAFjE0RWVRNXJmemJBOHpxZUMxSEtJTzgAAQA1aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLzE0RWVRNXJmemJBOHpxZUMxSEtJTzgBAEBodHRwczovL2kuc2Nkbi5jby9pbWFnZS9hYjY3NjE2ZDAwMDBiMjczMzFhZDFhZTFjNzFkNDY5NWI1NDExYmU3AQAMS1JBMzQxNDA0NzEwAAdzcG90aWZ5AQA7TUJDIOuCtOyGkOydhOyeoeyVhCwgUHQuIDQgKE9yaWdpbmFsIFRlbGV2aXNpb24gU291bmR0cmFjaykBADVodHRwczovL29wZW4uc3BvdGlmeS5jb20vYWxidW0vNGQ2Tzd5V0VEOVN1aWdybXYzREpOdAEANmh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS9hcnRpc3QvMEVscWJGZnFvaDBqNElmTlVmdjA0dgEAQGh0dHBzOi8vaS5zY2RuLmNvL2ltYWdlL2FiNjc2MTZkMDAwMGIyNzM3MjhjZGZmN2M1MDRjNjkzYmE3YWZkYTAAAAAAAAAAAAAA",
"info": {
"identifier": "14EeQ5rfzbA8zqeC1HKIO8",
"isSeekable": true,
"author": "Kim Yong Jin",
"length": 206315,
"isStream": false,
"position": 0,
"title": "기다린다",
"uri": "https://open.spotify.com/track/14EeQ5rfzbA8zqeC1HKIO8",
"sourceName": "spotify",
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b27331ad1ae1c71d4695b5411be7",
"isrc": "KRA341404710"
},
"pluginInfo": {
"albumUrl": "https://open.spotify.com/album/4d6O7yWED9Suigrmv3DJNt",
"albumName": "MBC 내손을잡아, Pt. 4 (Original Television Soundtrack)",
"previewUrl": null,
"isPreview": false,
"artistUrl": "https://open.spotify.com/artist/0ElqbFfqoh0j4IfNUfv04v",
"artistArtworkUrl": "https://i.scdn.co/image/ab67616d0000b273728cdff7c504c693ba7afda0",
"isLocal": false
},
"userData": {}
},
{
"encoded": "QAABygMADOq3uOugpOuzuOuLpAAEU3VraQAAAAAABFy8ABYwNXFtWHR3SlNBemNBM0tHWk9MM2ZyAAEANWh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS90cmFjay8wNXFtWHR3SlNBemNBM0tHWk9MM2ZyAQBAaHR0cHM6Ly9pLnNjZG4uY28vaW1hZ2UvYWI2NzYxNmQwMDAwYjI3MzA1NjE0YTkxOWVhNzM1ZjcxNWI3OGIyOAEADEtSQTM0MTQwMzc0NgAHc3BvdGlmeQEAQE1CQyDrgrTshpDsnYTsnqHslYQgUHQuIDMgKE9yaWdpbmFsIFRlbGV2aXNpb24gU291bmR0cmFjaykgUHQuIDMBADVodHRwczovL29wZW4uc3BvdGlmeS5jb20vYWxidW0vMDRDcXB1QU5qUUxUQ00xM2l6b2hDVwEANmh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS9hcnRpc3QvM252ZWhHbVNXdTJqbG5XU0MyRWFjSAEAQGh0dHBzOi8vaS5zY2RuLmNvL2ltYWdlL2FiNjc2MTZkMDAwMGIyNzNlNmJhMzE5NmRjYWVkMDljOTc3NDU1ZGEAAAAAAAAAAAAA",
"info": {
"identifier": "05qmXtwJSAzcA3KGZOL3fr",
"isSeekable": true,
"author": "Suki",
"length": 285884,
"isStream": false,
"position": 0,
"title": "그려본다",
"uri": "https://open.spotify.com/track/05qmXtwJSAzcA3KGZOL3fr",
"sourceName": "spotify",
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b27305614a919ea735f715b78b28",
"isrc": "KRA341403746"
},
"pluginInfo": {
"albumUrl": "https://open.spotify.com/album/04CqpuANjQLTCM13izohCW",
"albumName": "MBC 내손을잡아 Pt. 3 (Original Television Soundtrack) Pt. 3",
"previewUrl": null,
"isPreview": false,
"artistUrl": "https://open.spotify.com/artist/3nvehGmSWu2jlnWSC2EacH",
"artistArtworkUrl": "https://i.scdn.co/image/ab67616d0000b273e6ba3196dcaed09c977455da",
"isLocal": false
},
"userData": {}
},
{
"encoded": "QAAByAMAE+yVhO2UhOqzoCDslYTtjIzshJwABuynhOyjvAAAAAAAA0WNABYzUjVZMzBrR0p4S3J6SUlHU2JCVEdnAAEANWh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS90cmFjay8zUjVZMzBrR0p4S3J6SUlHU2JCVEdnAQBAaHR0cHM6Ly9pLnNjZG4uY28vaW1hZ2UvYWI2NzYxNmQwMDAwYjI3M2NmNGY3NDVhNThkMGU2OWNiNmY2OGZmZQEADEtSQTM0MTQwMzI4OQAHc3BvdGlmeQEANU1CQyDrgrTshpDsnYTsnqHslYQsIChPcmlnaW5hbCBUZWxldmlzaW9uIFNvdW5kdHJhY2spAQA1aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLzc0aEJ2RTBzNTRlQ0RmcVdNRFJRaHEBADZodHRwczovL29wZW4uc3BvdGlmeS5jb20vYXJ0aXN0LzFqQ2lHZWVtZVZlR1BlOVlieENPdXYBAEBodHRwczovL2kuc2Nkbi5jby9pbWFnZS9hYjY3NjE2ZDAwMDBiMjczNGE2MGVjMzE0YmEwNDMzOGI1MWM3YzQzAAAAAAAAAAAAAA==",
"info": {
"identifier": "3R5Y30kGJxKrzIIGSbBTGg",
"isSeekable": true,
"author": "진주",
"length": 214413,
"isStream": false,
"position": 0,
"title": "아프고 아파서",
"uri": "https://open.spotify.com/track/3R5Y30kGJxKrzIIGSbBTGg",
"sourceName": "spotify",
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b273cf4f745a58d0e69cb6f68ffe",
"isrc": "KRA341403289"
},
"pluginInfo": {
"albumUrl": "https://open.spotify.com/album/74hBvE0s54eCDfqWMDRQhq",
"albumName": "MBC 내손을잡아, (Original Television Soundtrack)",
"previewUrl": null,
"isPreview": false,
"artistUrl": "https://open.spotify.com/artist/1jCiGeemeVeGPe9YbxCOuv",
"artistArtworkUrl": "https://i.scdn.co/image/ab67616d0000b2734a60ec314ba04338b51c7c43",
"isLocal": false
},
"userData": {}
},
{
"encoded": "QAAB2QMAGOyCrOueke2VtCAtIEluc3RydW1lbnRhbAAG7Iug7JygAAAAAAADvg8AFjZOR2xabmhxOEFySnFRaUtoM3lpNFEAAQA1aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLzZOR2xabmhxOEFySnFRaUtoM3lpNFEBAEBodHRwczovL2kuc2Nkbi5jby9pbWFnZS9hYjY3NjE2ZDAwMDBiMjczMDQ2ODdjMjljNWI4ZTBlNjc5ODc1NzQ2AQAMS1JBMzQxNDAyNjYwAAdzcG90aWZ5AQBBTUJDIOuCtOyGkOydhOyeoeyVhCwgUHQuIDEgKE9yaWdpbmFsIFRlbGV2aXNpb24gU291bmR0cmFjaykgUHQuIDEBADVodHRwczovL29wZW4uc3BvdGlmeS5jb20vYWxidW0vMVVpazNqV0tTSWdnNktISkRrRDBaagEANmh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS9hcnRpc3QvNWFFa3ZDZWRNM2FTMG11ZlY2cWtOSwEAQGh0dHBzOi8vaS5zY2RuLmNvL2ltYWdlL2FiNjc2MTZkMDAwMGIyNzMzNzEzMzFiZjA5ZDQ0NjQ5YTVlYzU2ODkAAAAAAAAAAAAA",
"info": {
"identifier": "6NGlZnhq8ArJqQiKh3yi4Q",
"isSeekable": true,
"author": "신유",
"length": 245263,
"isStream": false,
"position": 0,
"title": "사랑해 - Instrumental",
"uri": "https://open.spotify.com/track/6NGlZnhq8ArJqQiKh3yi4Q",
"sourceName": "spotify",
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b27304687c29c5b8e0e679875746",
"isrc": "KRA341402660"
},
"pluginInfo": {
"albumUrl": "https://open.spotify.com/album/1Uik3jWKSIgg6KHJDkD0Zj",
"albumName": "MBC 내손을잡아, Pt. 1 (Original Television Soundtrack) Pt. 1",
"previewUrl": null,
"isPreview": false,
"artistUrl": "https://open.spotify.com/artist/5aEkvCedM3aS0mufV6qkNK",
"artistArtworkUrl": "https://i.scdn.co/image/ab67616d0000b273371331bf09d44649a5ec5689",
"isLocal": false
},
"userData": {}
},
{
"encoded": "QAAB2wMAIOqwgOyKtCDslYTtlIgg64KgIC0gSW5zdHJ1bWVudGFsAAbshLHruYgAAAAAAAO3vAAWNUE5dkNKYmY2eDNMUWk2VmlZUmRwYwABADVodHRwczovL29wZW4uc3BvdGlmeS5jb20vdHJhY2svNUE5dkNKYmY2eDNMUWk2VmlZUmRwYwEAQGh0dHBzOi8vaS5zY2RuLmNvL2ltYWdlL2FiNjc2MTZkMDAwMGIyNzNlNmU4ZmFmYjVkYTdhNjE2YjBjYTg1NjcBAAxLUkEzNDE0MDUxNzEAB3Nwb3RpZnkBADtNQkMg64K07IaQ7J2E7J6h7JWELCBQdC4gNSAoT3JpZ2luYWwgVGVsZXZpc2lvbiBTb3VuZHRyYWNrKQEANWh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS9hbGJ1bS8yY0hRU1NONzlHNjBsdEUzNGpGQWZsAQA2aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FydGlzdC8xRTRmMElVMFlCTkFzTllNc3hHa0hMAQBAaHR0cHM6Ly9pLnNjZG4uY28vaW1hZ2UvYWI2NzYxNmQwMDAwYjI3M2U2ZThmYWZiNWRhN2E2MTZiMGNhODU2NwAAAAAAAAAAAAA=",
"info": {
"identifier": "5A9vCJbf6x3LQi6ViYRdpc",
"isSeekable": true,
"author": "성빈",
"length": 243644,
"isStream": false,
"position": 0,
"title": "가슴 아픈 날 - Instrumental",
"uri": "https://open.spotify.com/track/5A9vCJbf6x3LQi6ViYRdpc",
"sourceName": "spotify",
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b273e6e8fafb5da7a616b0ca8567",
"isrc": "KRA341405171"
},
"pluginInfo": {
"albumUrl": "https://open.spotify.com/album/2cHQSSN79G60ltE34jFAfl",
"albumName": "MBC 내손을잡아, Pt. 5 (Original Television Soundtrack)",
"previewUrl": null,
"isPreview": false,
"artistUrl": "https://open.spotify.com/artist/1E4f0IU0YBNAsNYMsxGkHL",
"artistArtworkUrl": "https://i.scdn.co/image/ab67616d0000b273e6e8fafb5da7a616b0ca8567",
"isLocal": false
},
"userData": {}
},
{
"encoded": "QAABvwMADOq3uOugpOuzuOuLpAAEU3VraQAAAAAABFy8ABYxOWQ2MUZleHViaXU2NXVpUVhKY25xAAEANWh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS90cmFjay8xOWQ2MUZleHViaXU2NXVpUVhKY25xAQBAaHR0cHM6Ly9pLnNjZG4uY28vaW1hZ2UvYWI2NzYxNmQwMDAwYjI3M2NmNGY3NDVhNThkMGU2OWNiNmY2OGZmZQEADEtSQTM0MTQwMzc0NgAHc3BvdGlmeQEANU1CQyDrgrTshpDsnYTsnqHslYQsIChPcmlnaW5hbCBUZWxldmlzaW9uIFNvdW5kdHJhY2spAQA1aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLzc0aEJ2RTBzNTRlQ0RmcVdNRFJRaHEBADZodHRwczovL29wZW4uc3BvdGlmeS5jb20vYXJ0aXN0LzNudmVoR21TV3UyamxuV1NDMkVhY0gBAEBodHRwczovL2kuc2Nkbi5jby9pbWFnZS9hYjY3NjE2ZDAwMDBiMjczZTZiYTMxOTZkY2FlZDA5Yzk3NzQ1NWRhAAAAAAAAAAAAAA==",
"info": {
"identifier": "19d61Fexubiu65uiQXJcnq",
"isSeekable": true,
"author": "Suki",
"length": 285884,
"isStream": false,
"position": 0,
"title": "그려본다",
"uri": "https://open.spotify.com/track/19d61Fexubiu65uiQXJcnq",
"sourceName": "spotify",
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b273cf4f745a58d0e69cb6f68ffe",
"isrc": "KRA341403746"
},
"pluginInfo": {
"albumUrl": "https://open.spotify.com/album/74hBvE0s54eCDfqWMDRQhq",
"albumName": "MBC 내손을잡아, (Original Television Soundtrack)",
"previewUrl": null,
"isPreview": false,
"artistUrl": "https://open.spotify.com/artist/3nvehGmSWu2jlnWSC2EacH",
"artistArtworkUrl": "https://i.scdn.co/image/ab67616d0000b273e6ba3196dcaed09c977455da",
"isLocal": false
},
"userData": {}
},
{
"encoded": "QAAB4wMAIuyVhO2UhOqzoCDslYTtjIzshJwgLSBJbnN0cnVtZW50YWwABuynhOyjvAAAAAAAA0WNABYzYW8wSkxJWDRYdDc5RWg2YXh6dVFCAAEANWh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS90cmFjay8zYW8wSkxJWDRYdDc5RWg2YXh6dVFCAQBAaHR0cHM6Ly9pLnNjZG4uY28vaW1hZ2UvYWI2NzYxNmQwMDAwYjI3MzA4NmRkMWZkZTY2NGU2MjM4OWY4ZmJiZgEADEtSQTM0MTQwMzI5MAAHc3BvdGlmeQEAQU1CQyDrgrTshpDsnYTsnqHslYQsIFB0LiAyIChPcmlnaW5hbCBUZWxldmlzaW9uIFNvdW5kdHJhY2spIFB0LiAyAQA1aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLzBIMllUNjI5aE56Y1pGTHg0cWNNMU8BADZodHRwczovL29wZW4uc3BvdGlmeS5jb20vYXJ0aXN0LzFqQ2lHZWVtZVZlR1BlOVlieENPdXYBAEBodHRwczovL2kuc2Nkbi5jby9pbWFnZS9hYjY3NjE2ZDAwMDBiMjczNGE2MGVjMzE0YmEwNDMzOGI1MWM3YzQzAAAAAAAAAAAAAA==",
"info": {
"identifier": "3ao0JLIX4Xt79Eh6axzuQB",
"isSeekable": true,
"author": "진주",
"length": 214413,
"isStream": false,
"position": 0,
"title": "아프고 아파서 - Instrumental",
"uri": "https://open.spotify.com/track/3ao0JLIX4Xt79Eh6axzuQB",
"sourceName": "spotify",
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b273086dd1fde664e62389f8fbbf",
"isrc": "KRA341403290"
},
"pluginInfo": {
"albumUrl": "https://open.spotify.com/album/0H2YT629hNzcZFLx4qcM1O",
"albumName": "MBC 내손을잡아, Pt. 2 (Original Television Soundtrack) Pt. 2",
"previewUrl": null,
"isPreview": false,
"artistUrl": "https://open.spotify.com/artist/1jCiGeemeVeGPe9YbxCOuv",
"artistArtworkUrl": "https://i.scdn.co/image/ab67616d0000b2734a60ec314ba04338b51c7c43",
"isLocal": false
},
"userData": {}
},
{
"encoded": "QAAB2gMAGeyCrOueke2VtCAtIFBpYW5vIFZlcnNpb24ABuyLoOycoAAAAAAABETbABYxMFFrU05zTklkQ0ZxQUhEOHpINTdPAAEANWh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS90cmFjay8xMFFrU05zTklkQ0ZxQUhEOHpINTdPAQBAaHR0cHM6Ly9pLnNjZG4uY28vaW1hZ2UvYWI2NzYxNmQwMDAwYjI3MzA0Njg3YzI5YzViOGUwZTY3OTg3NTc0NgEADEtSQTM0MTQwMjY1OQAHc3BvdGlmeQEAQU1CQyDrgrTshpDsnYTsnqHslYQsIFB0LiAxIChPcmlnaW5hbCBUZWxldmlzaW9uIFNvdW5kdHJhY2spIFB0LiAxAQA1aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLzFVaWszaldLU0lnZzZLSEpEa0QwWmoBADZodHRwczovL29wZW4uc3BvdGlmeS5jb20vYXJ0aXN0LzVhRWt2Q2VkTTNhUzBtdWZWNnFrTksBAEBodHRwczovL2kuc2Nkbi5jby9pbWFnZS9hYjY3NjE2ZDAwMDBiMjczMzcxMzMxYmYwOWQ0NDY0OWE1ZWM1Njg5AAAAAAAAAAAAAA==",
"info": {
"identifier": "10QkSNsNIdCFqAHD8zH57O",
"isSeekable": true,
"author": "신유",
"length": 279771,
"isStream": false,
"position": 0,
"title": "사랑해 - Piano Version",
"uri": "https://open.spotify.com/track/10QkSNsNIdCFqAHD8zH57O",
"sourceName": "spotify",
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b27304687c29c5b8e0e679875746",
"isrc": "KRA341402659"
},
"pluginInfo": {
"albumUrl": "https://open.spotify.com/album/1Uik3jWKSIgg6KHJDkD0Zj",
"albumName": "MBC 내손을잡아, Pt. 1 (Original Television Soundtrack) Pt. 1",
"previewUrl": null,
"isPreview": false,
"artistUrl": "https://open.spotify.com/artist/5aEkvCedM3aS0mufV6qkNK",
"artistArtworkUrl": "https://i.scdn.co/image/ab67616d0000b273371331bf09d44649a5ec5689",
"isLocal": false
},
"userData": {}
},
{
"encoded": "QAAB2QMAG+q3uOugpOuzuOuLpCAtIEluc3RydW1lbnRhbAAEU3VraQAAAAAABFy8ABYycjhVemduSmZTTDhuWTc4T3NRUXlRAAEANWh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS90cmFjay8ycjhVemduSmZTTDhuWTc4T3NRUXlRAQBAaHR0cHM6Ly9pLnNjZG4uY28vaW1hZ2UvYWI2NzYxNmQwMDAwYjI3MzA1NjE0YTkxOWVhNzM1ZjcxNWI3OGIyOAEADEtSQTM0MTQwMzc0NwAHc3BvdGlmeQEAQE1CQyDrgrTshpDsnYTsnqHslYQgUHQuIDMgKE9yaWdpbmFsIFRlbGV2aXNpb24gU291bmR0cmFjaykgUHQuIDMBADVodHRwczovL29wZW4uc3BvdGlmeS5jb20vYWxidW0vMDRDcXB1QU5qUUxUQ00xM2l6b2hDVwEANmh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS9hcnRpc3QvM252ZWhHbVNXdTJqbG5XU0MyRWFjSAEAQGh0dHBzOi8vaS5zY2RuLmNvL2ltYWdlL2FiNjc2MTZkMDAwMGIyNzNlNmJhMzE5NmRjYWVkMDljOTc3NDU1ZGEAAAAAAAAAAAAA",
"info": {
"identifier": "2r8UzgnJfSL8nY78OsQQyQ",
"isSeekable": true,
"author": "Suki",
"length": 285884,
"isStream": false,
"position": 0,
"title": "그려본다 - Instrumental",
"uri": "https://open.spotify.com/track/2r8UzgnJfSL8nY78OsQQyQ",
"sourceName": "spotify",
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b27305614a919ea735f715b78b28",
"isrc": "KRA341403747"
},
"pluginInfo": {
"albumUrl": "https://open.spotify.com/album/04CqpuANjQLTCM13izohCW",
"albumName": "MBC 내손을잡아 Pt. 3 (Original Television Soundtrack) Pt. 3",
"previewUrl": null,
"isPreview": false,
"artistUrl": "https://open.spotify.com/artist/3nvehGmSWu2jlnWSC2EacH",
"artistArtworkUrl": "https://i.scdn.co/image/ab67616d0000b273e6ba3196dcaed09c977455da",
"isLocal": false
},
"userData": {}
},
{
"encoded": "QAABvgMACeyCrOueke2VtAAG7Iug7JygAAAAAAADvg8AFjNYUEQxMEhuZ2ZaM0dsOVY3TWhrWGQAAQA1aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLzNYUEQxMEhuZ2ZaM0dsOVY3TWhrWGQBAEBodHRwczovL2kuc2Nkbi5jby9pbWFnZS9hYjY3NjE2ZDAwMDBiMjczY2Y0Zjc0NWE1OGQwZTY5Y2I2ZjY4ZmZlAQAMS1JBMzQxNDA1NTYyAAdzcG90aWZ5AQA1TUJDIOuCtOyGkOydhOyeoeyVhCwgKE9yaWdpbmFsIFRlbGV2aXNpb24gU291bmR0cmFjaykBADVodHRwczovL29wZW4uc3BvdGlmeS5jb20vYWxidW0vNzRoQnZFMHM1NGVDRGZxV01EUlFocQEANmh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS9hcnRpc3QvNWFFa3ZDZWRNM2FTMG11ZlY2cWtOSwEAQGh0dHBzOi8vaS5zY2RuLmNvL2ltYWdlL2FiNjc2MTZkMDAwMGIyNzMzNzEzMzFiZjA5ZDQ0NjQ5YTVlYzU2ODkAAAAAAAAAAAAA",
"info": {
"identifier": "3XPD10HngfZ3Gl9V7MhkXd",
"isSeekable": true,
"author": "신유",
"length": 245263,
"isStream": false,
"position": 0,
"title": "사랑해",
"uri": "https://open.spotify.com/track/3XPD10HngfZ3Gl9V7MhkXd",
"sourceName": "spotify",
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b273cf4f745a58d0e69cb6f68ffe",
"isrc": "KRA341405562"
},
"pluginInfo": {
"albumUrl": "https://open.spotify.com/album/74hBvE0s54eCDfqWMDRQhq",
"albumName": "MBC 내손을잡아, (Original Television Soundtrack)",
"previewUrl": null,
"isPreview": false,
"artistUrl": "https://open.spotify.com/artist/5aEkvCedM3aS0mufV6qkNK",
"artistArtworkUrl": "https://i.scdn.co/image/ab67616d0000b273371331bf09d44649a5ec5689",
"isLocal": false
},
"userData": {}
},
{
"encoded": "QAAB1AMAIOuCtCDshpDsnYQg7J6h7JWEIC0gSW5zdHJ1bWVudGFsAAVHaWxtZQAAAAAAAyG8ABYweXN2Y2RjNE9oM3hkTDFucXpESXVrAAEANWh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS90cmFjay8weXN2Y2RjNE9oM3hkTDFucXpESXVrAQBAaHR0cHM6Ly9pLnNjZG4uY28vaW1hZ2UvYWI2NzYxNmQwMDAwYjI3M2NmNGY3NDVhNThkMGU2OWNiNmY2OGZmZQEADEtSQTM0MTQwNTU2NAAHc3BvdGlmeQEANU1CQyDrgrTshpDsnYTsnqHslYQsIChPcmlnaW5hbCBUZWxldmlzaW9uIFNvdW5kdHJhY2spAQA1aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLzc0aEJ2RTBzNTRlQ0RmcVdNRFJRaHEBADZodHRwczovL29wZW4uc3BvdGlmeS5jb20vYXJ0aXN0LzU1alI0bUJxSXNMVUwwUzBTNUZkTGkBAEBodHRwczovL2kuc2Nkbi5jby9pbWFnZS9hYjY3NjE2ZDAwMDBiMjczYTM0MDY4Y2IyMTE0Mjc4ZWZiM2ExMTU3AAAAAAAAAAAAAA==",
"info": {
"identifier": "0ysvcdc4Oh3xdL1nqzDIuk",
"isSeekable": true,
"author": "Gilme",
"length": 205244,
"isStream": false,
"position": 0,
"title": "내 손을 잡아 - Instrumental",
"uri": "https://open.spotify.com/track/0ysvcdc4Oh3xdL1nqzDIuk",
"sourceName": "spotify",
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b273cf4f745a58d0e69cb6f68ffe",
"isrc": "KRA341405564"
},
"pluginInfo": {
"albumUrl": "https://open.spotify.com/album/74hBvE0s54eCDfqWMDRQhq",
"albumName": "MBC 내손을잡아, (Original Television Soundtrack)",
"previewUrl": null,
"isPreview": false,
"artistUrl": "https://open.spotify.com/artist/55jR4mBqIsLUL0S0S5FdLi",
"artistArtworkUrl": "https://i.scdn.co/image/ab67616d0000b273a34068cb2114278efb3a1157",
"isLocal": false
},
"userData": {}
},
{
"encoded": "QAAB3AMAG+q4sOuLpOumsOuLpCAtIEluc3RydW1lbnRhbAAMS2ltIFlvbmcgSmluAAAAAAADJgUAFjRqWTRabHJMZmI5cTZGRzFoSmRTQncAAQA1aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLzRqWTRabHJMZmI5cTZGRzFoSmRTQncBAEBodHRwczovL2kuc2Nkbi5jby9pbWFnZS9hYjY3NjE2ZDAwMDBiMjczMzFhZDFhZTFjNzFkNDY5NWI1NDExYmU3AQAMS1JBMzQxNDA0NzExAAdzcG90aWZ5AQA7TUJDIOuCtOyGkOydhOyeoeyVhCwgUHQuIDQgKE9yaWdpbmFsIFRlbGV2aXNpb24gU291bmR0cmFjaykBADVodHRwczovL29wZW4uc3BvdGlmeS5jb20vYWxidW0vNGQ2Tzd5V0VEOVN1aWdybXYzREpOdAEANmh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS9hcnRpc3QvMEVscWJGZnFvaDBqNElmTlVmdjA0dgEAQGh0dHBzOi8vaS5zY2RuLmNvL2ltYWdlL2FiNjc2MTZkMDAwMGIyNzM3MjhjZGZmN2M1MDRjNjkzYmE3YWZkYTAAAAAAAAAAAAAA",
"info": {
"identifier": "4jY4ZlrLfb9q6FG1hJdSBw",
"isSeekable": true,
"author": "Kim Yong Jin",
"length": 206341,
"isStream": false,
"position": 0,
"title": "기다린다 - Instrumental",
"uri": "https://open.spotify.com/track/4jY4ZlrLfb9q6FG1hJdSBw",
"sourceName": "spotify",
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b27331ad1ae1c71d4695b5411be7",
"isrc": "KRA341404711"
},
"pluginInfo": {
"albumUrl": "https://open.spotify.com/album/4d6O7yWED9Suigrmv3DJNt",
"albumName": "MBC 내손을잡아, Pt. 4 (Original Television Soundtrack)",
"previewUrl": null,
"isPreview": false,
"artistUrl": "https://open.spotify.com/artist/0ElqbFfqoh0j4IfNUfv04v",
"artistArtworkUrl": "https://i.scdn.co/image/ab67616d0000b273728cdff7c504c693ba7afda0",
"isLocal": false
},
"userData": {}
},
{
"encoded": "QAABxwMADOq4sOuLpOumsOuLpAAMS2ltIFlvbmcgSmluAAAAAAADJesAFjFMd1BlMnpyN2hENVEzblNsN3BVR20AAQA1aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLzFMd1BlMnpyN2hENVEzblNsN3BVR20BAEBodHRwczovL2kuc2Nkbi5jby9pbWFnZS9hYjY3NjE2ZDAwMDBiMjczY2Y0Zjc0NWE1OGQwZTY5Y2I2ZjY4ZmZlAQAMS1JBMzQxNDA0NzEwAAdzcG90aWZ5AQA1TUJDIOuCtOyGkOydhOyeoeyVhCwgKE9yaWdpbmFsIFRlbGV2aXNpb24gU291bmR0cmFjaykBADVodHRwczovL29wZW4uc3BvdGlmeS5jb20vYWxidW0vNzRoQnZFMHM1NGVDRGZxV01EUlFocQEANmh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS9hcnRpc3QvMEVscWJGZnFvaDBqNElmTlVmdjA0dgEAQGh0dHBzOi8vaS5zY2RuLmNvL2ltYWdlL2FiNjc2MTZkMDAwMGIyNzM3MjhjZGZmN2M1MDRjNjkzYmE3YWZkYTAAAAAAAAAAAAAA",
"info": {
"identifier": "1LwPe2zr7hD5Q3nSl7pUGm",
"isSeekable": true,
"author": "Kim Yong Jin",
"length": 206315,
"isStream": false,
"position": 0,
"title": "기다린다",
"uri": "https://open.spotify.com/track/1LwPe2zr7hD5Q3nSl7pUGm",
"sourceName": "spotify",
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b273cf4f745a58d0e69cb6f68ffe",
"isrc": "KRA341404710"
},
"pluginInfo": {
"albumUrl": "https://open.spotify.com/album/74hBvE0s54eCDfqWMDRQhq",
"albumName": "MBC 내손을잡아, (Original Television Soundtrack)",
"previewUrl": null,
"isPreview": false,
"artistUrl": "https://open.spotify.com/artist/0ElqbFfqoh0j4IfNUfv04v",
"artistArtworkUrl": "https://i.scdn.co/image/ab67616d0000b273728cdff7c504c693ba7afda0",
"isLocal": false
},
"userData": {}
},
{
"encoded": "QAABzAMAEeqwgOyKtCDslYTtlIgg64KgAAbshLHruYgAAAAAAAO31gAWNWU3T1d3bHpxYzlmNlE4YjI2YklhdQABADVodHRwczovL29wZW4uc3BvdGlmeS5jb20vdHJhY2svNWU3T1d3bHpxYzlmNlE4YjI2YklhdQEAQGh0dHBzOi8vaS5zY2RuLmNvL2ltYWdlL2FiNjc2MTZkMDAwMGIyNzNlNmU4ZmFmYjVkYTdhNjE2YjBjYTg1NjcBAAxLUkEzNDE0MDUxNzAAB3Nwb3RpZnkBADtNQkMg64K07IaQ7J2E7J6h7JWELCBQdC4gNSAoT3JpZ2luYWwgVGVsZXZpc2lvbiBTb3VuZHRyYWNrKQEANWh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS9hbGJ1bS8yY0hRU1NONzlHNjBsdEUzNGpGQWZsAQA2aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FydGlzdC8xRTRmMElVMFlCTkFzTllNc3hHa0hMAQBAaHR0cHM6Ly9pLnNjZG4uY28vaW1hZ2UvYWI2NzYxNmQwMDAwYjI3M2U2ZThmYWZiNWRhN2E2MTZiMGNhODU2NwAAAAAAAAAAAAA=",
"info": {
"identifier": "5e7OWwlzqc9f6Q8b26bIau",
"isSeekable": true,
"author": "성빈",
"length": 243670,
"isStream": false,
"position": 0,
"title": "가슴 아픈 날",
"uri": "https://open.spotify.com/track/5e7OWwlzqc9f6Q8b26bIau",
"sourceName": "spotify",
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b273e6e8fafb5da7a616b0ca8567",
"isrc": "KRA341405170"
},
"pluginInfo": {
"albumUrl": "https://open.spotify.com/album/2cHQSSN79G60ltE34jFAfl",
"albumName": "MBC 내손을잡아, Pt. 5 (Original Television Soundtrack)",
"previewUrl": null,
"isPreview": false,
"artistUrl": "https://open.spotify.com/artist/1E4f0IU0YBNAsNYMsxGkHL",
"artistArtworkUrl": "https://i.scdn.co/image/ab67616d0000b273e6e8fafb5da7a616b0ca8567",
"isLocal": false
},
"userData": {}
},
{
"encoded": "QAABxgMAEeqwgOyKtCDslYTtlIgg64KgAAbshLHruYgAAAAAAAO31gAWMjVpUm52MldXTzJ0UXNuenBBWDN6aAABADVodHRwczovL29wZW4uc3BvdGlmeS5jb20vdHJhY2svMjVpUm52MldXTzJ0UXNuenBBWDN6aAEAQGh0dHBzOi8vaS5zY2RuLmNvL2ltYWdlL2FiNjc2MTZkMDAwMGIyNzNjZjRmNzQ1YTU4ZDBlNjljYjZmNjhmZmUBAAxLUkEzNDE0MDU1NjMAB3Nwb3RpZnkBADVNQkMg64K07IaQ7J2E7J6h7JWELCAoT3JpZ2luYWwgVGVsZXZpc2lvbiBTb3VuZHRyYWNrKQEANWh0dHBzOi8vb3Blbi5zcG90aWZ5LmNvbS9hbGJ1bS83NGhCdkUwczU0ZUNEZnFXTURSUWhxAQA2aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FydGlzdC8xRTRmMElVMFlCTkFzTllNc3hHa0hMAQBAaHR0cHM6Ly9pLnNjZG4uY28vaW1hZ2UvYWI2NzYxNmQwMDAwYjI3M2U2ZThmYWZiNWRhN2E2MTZiMGNhODU2NwAAAAAAAAAAAAA=",
"info": {
"identifier": "25iRnv2WWO2tQsnzpAX3zh",
"isSeekable": true,
"author": "성빈",
"length": 243670,
"isStream": false,
"position": 0,
"title": "가슴 아픈 날",
"uri": "https://open.spotify.com/track/25iRnv2WWO2tQsnzpAX3zh",
"sourceName": "spotify",
"artworkUrl": "https://i.scdn.co/image/ab67616d0000b273cf4f745a58d0e69cb6f68ffe",
"isrc": "KRA341405563"
},
"pluginInfo": {
"albumUrl": "https://open.spotify.com/album/74hBvE0s54eCDfqWMDRQhq",
"albumName": "MBC 내손을잡아, (Original Television Soundtrack)",
"previewUrl": null,
"isPreview": false,
"artistUrl": "https://open.spotify.com/artist/1E4f0IU0YBNAsNYMsxGkHL",
"artistArtworkUrl": "https://i.scdn.co/image/ab67616d0000b273e6e8fafb5da7a616b0ca8567",
"isLocal": false
},
"userData": {}
}
]

View File

@@ -0,0 +1,76 @@
import { Client, ClientEvents, ColorResolvable, EmbedBuilder, EmbedField, GatewayIntentBits, Message } from "discord.js";
import { Config } from "../utils/Config";
export class BotClient extends Client {
public prefix = Config.prefix;
public color: ColorResolvable = Config.embedColor;
public constructor() {
super({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildVoiceStates,
],
});
}
public async start() {
this.login(Config.token);
}
/**
* 이벤트 핸들러 등록
*
* 지정한 이벤트가 발생했을때 해당 핸들러를 호출함
* * 'func'의 내용은 기본적으로 'client.on'을 따름
* * 'extra'를 입력할 경우 추가되어 같이 전달
*
* @example
* client.onEvent('ready', (client, info) => {
* Logger.ready(client?.user.username, '봇이 준비되었습니다.', info) // 출력: OOO 봇이 준비되었습니다. 추가 정보
* }, ['추가 정보']);
*
* @param event 이벤트명
* @param func 이벤트 핸들러 함수
* @param extra 추가로 전달할 목록
*/
public readonly onEvent = (event: keyof ClientEvents, func: Function, ...extra: any[]) => this.on(event, (...args) => func(...args, ...extra));
public mkembed(data: {
title?: string;
description?: string;
url?: string;
image?: string;
thumbnail?: string;
author?: { name: string, iconURL?: string, url?: string };
addFields?: EmbedField[];
timestamp?: number | Date | undefined | null;
footer?: { text: string, iconURL?: string };
color?: ColorResolvable;
}): EmbedBuilder {
if (!data.color) data.color = this.color;
const embed = new EmbedBuilder();
if (data.title) embed.setTitle(data.title);
if (data.description) embed.setDescription(data.description);
if (data.url) embed.setURL(data.url);
if (data.image) embed.setImage(data.image);
if (data.thumbnail) embed.setThumbnail(data.thumbnail);
if (data.author) embed.setAuthor({ name: data.author.name, iconURL: data.author.iconURL, url: data.author.url });
if (data.addFields) embed.addFields(data.addFields);
if (data.timestamp) embed.setTimestamp(data.timestamp);
if (data.footer) embed.setFooter({ text: data.footer.text, iconURL: data.footer.iconURL });
if (data.color) embed.setColor(data.color);
return embed;
}
public msgDelete(message: Message, time: number, customTime?: boolean): void {
setTimeout(async () => {
try {
const msg = await message.fetch(true).catch(() => undefined);
if (msg?.deletable) msg.delete().catch(() => {});
} catch {};
}, Math.max(100, time * (customTime ? 1 : 6000)));
}
}

View File

@@ -0,0 +1,278 @@
import { Guild, Message, TextChannel } from "discord.js";
import { LoadType, Player, Track, TrackEndEvent } from "shoukaku";
import { client, lavalinkManager } from "../index";
import { timeFormat } from "../utils/music/Utils";
import { default_content, default_embed, default_image, getButtons } from "../utils/music/Config";
import { GuildType } from "../../db/db";
import { DB } from "../utils/Database";
import { shuffle } from "../utils/Shuffle";
import { checkTextChannelAndMsg } from "../utils/music/Channel";
const DelayAfterErrMs = 1000 * 5;
const idleEndTime = 1000 * 60 * 10;
type QueueTrack = Track & { userId: string; };
export class GuildPlayer {
private isDead = false;
private canRecommend = true;
private _GDB: GuildType | undefined;
private lastPlayedTrack: QueueTrack | undefined;
public queue: QueueTrack[] = [];
private errorTimer: NodeJS.Timeout | undefined;
private endTimer: NodeJS.Timeout | undefined;
constructor(
public guild: Guild,
public player: Player,
public voiceChannelId: string,
public textChannel: TextChannel,
public msg: Message,
) {
this.player.setGlobalVolume(50);
this.player.on("end", async (data: TrackEndEvent) => {
if (this.isDead) return;
if (data.reason === "replaced") return;
// 방금 끝난 곡을 대기열에서 지우면서 lastPlayedTrack에 저장
this.lastPlayedTrack = this.queue.shift();
if (this.queue.length > 0) {
await this.playNext();
} else if (this.isRecommend && this.canRecommend) {
await this.autoPlay();
} else {
this.end();
}
});
this.player.on("closed", () => {
if (this.isDead) return;
this.delete();
});
}
private get GDB() {
if (!this._GDB) this._GDB = DB.guild.get(this.guild.id);
return this._GDB;
}
public get isPlaying(): boolean {
return this.player.track !== null;
}
public get nowTrack(): QueueTrack | null {
return this.queue[0] ?? null;
}
public get isPaused(): boolean {
return this.player.paused;
}
public get isRecommend(): boolean {
return this.GDB?.options.recommend ?? false;
}
public async addTrack(track: Track, userId: string) {
this.queue.push({ ...track, userId });
if (this.queue.length === 1) {
// 큐가 비어있으면 바로 재생
await this.playNext();
} else {
// 재생목록 추가되었을때
this.setMsg();
}
}
public async addTracks(tracks: Track[], userId: string) {
const first = this.queue.length === 0;
for (const track of tracks) this.queue.push({ ...track, userId });
if (first) {
// 큐가 비어있으면 바로 재생
await this.playNext();
} else {
// 재생목록 추가되었을때
this.setMsg();
}
}
public async playNext() {
const track = this.queue[0];
if (!track) return this.end();
await this.player.playTrack({ track: { encoded: track.encoded } });
// 재생관련
this.setMsg();
}
public async setPause() {
if (this.isPaused) {
await this.player.setPaused(false);
this.setMsg();
} else if (this.isPlaying) {
await this.player.setPaused(true);
this.setMsg();
}
}
public stop() {
this.queue = [];
this.canRecommend = false;
this.player.stopTrack();
}
public skip() {
// 곡 스킵했을때
this.player.stopTrack();
}
public setShuffle() {
let nowQueue = this.queue.slice(1);
nowQueue = shuffle(nowQueue);
this.queue = [this.queue[0], ...nowQueue];
this.setMsg();
}
public setRecommend() {
if (!this.GDB) return;
this.GDB.options.recommend = !this.GDB.options.recommend;
DB.guild.update(this.GDB);
this.setMsg();
}
public seek(num: number) {
if (!this.isPlaying) return;
if (!this.nowTrack) return;
this.player.seekTo(num);
}
private async autoPlay() {
if (!this.lastPlayedTrack) return;
const source = this.lastPlayedTrack.info.sourceName;
const trackId = this.lastPlayedTrack.info.identifier;
let result;
if (source === "spotify") {
result = await this.player.node.rest.resolve(`sprec:seed_tracks=${trackId}&limit=11`);
} else {
result = await this.player.node.rest.resolve(`https://music.youtube.com/watch?v=${trackId}&list=RD${trackId}`);
}
let tracks: Track[] = [];
if (result?.loadType === LoadType.PLAYLIST) {
tracks = result.data.tracks;
} else if (result?.loadType === LoadType.SEARCH || Array.isArray(result?.data)) {
tracks = Array.isArray(result.data) ? result.data : [];
} else if (result?.loadType === LoadType.TRACK) {
tracks = [ result.data ];
}
if (tracks.length === 0) {
this.end();
return;
}
if (tracks[0].info.identifier === trackId) tracks = tracks.slice(1);
this.addTracks(tracks, "자동재생");
}
public end() {
if (this.errorTimer !== undefined) {
clearTimeout(this.errorTimer);
this.errorTimer = undefined;
}
if (this.endTimer !== undefined) clearTimeout(this.endTimer);
this.endTimer = setTimeout(() => {
this.endTimer = undefined;
this.delete(true);
}, idleEndTime);
this.lastPlayedTrack = undefined;
this.queue = [];
this.canRecommend = true;
this.setMsg();
}
public delete(afterEnd?: boolean) {
if (this.isDead) return;
if (!afterEnd) this.end();
this.isDead = true;
this.player.destroy().catch(() => {});
lavalinkManager.delPlayer(this.guild.id);
lavalinkManager.shoukaku.leaveVoiceChannel(this.guild.id);
}
private async setMsg() {
const { channel, msg, check } = await checkTextChannelAndMsg(this.guild, this.textChannel, this.msg);
if (!check) return;
this.textChannel = channel;
this.msg = msg;
if (!this.msg) return;
// content
let content = default_content;
let realQueue = this.queue.slice(1);
if (realQueue.length > 0) {
content = "__**대기열 목록:**__\n";
let list: string[] = [];
let nowLength = content.length + 50; // 50자 여유두기
for (let i=0; i<realQueue.length; i++) {
let track = realQueue[i];
let text = `${
(i+1).toString().padStart(realQueue.length.toString().length, "0")
}\\. ${
track.info.author.replace(" - Topic","")
} - ${
track.info.title
} [${
timeFormat(track.info.length)
}] ~ ${
track.userId === "자동재생" ? "자동재생" : `<@${track.userId}>`
}`;
if (nowLength + text.length > 2000) {
content += `+ ${realQueue.length-list.length}\n`;
break;
}
nowLength += text.length;
list.push(text);
}
content += list.reverse().join("\n");
}
// embed
const embed = (this.isPlaying || this.isPaused) && !!this.nowTrack
? client.mkembed({
title: `**[${timeFormat(this.nowTrack.info.length)}] ${this.nowTrack.info.author.replace(" - Topic","")} - ${this.nowTrack.info.title}**`,
description: `노래 요청자: ${this.nowTrack.userId === "자동재생" ? "자동재생" : `<@${this.nowTrack.userId}>`}`,
image: this.nowTrack.info.artworkUrl,
url: this.nowTrack.info.author.includes(" - Topic") ? this.nowTrack.info.uri?.replace("www.youtube","music.youtube") : this.nowTrack.info.uri,
footer: { text: `대기열: ${realQueue.length}개 | 볼륨: ${this.player.volume}${
this.isRecommend ? " | 자동재생: 활성화" : ""
}${
this.isPaused ? " | 노래가 일시중지 되었습니다." : ""
}` }
})
: default_embed(this.guild.id);
this.msg.edit({
content: content,
embeds: [ embed ],
files: embed.data.image?.url === default_embed(this.guild.id).data.image?.url ? [ default_image ] : [],
components: [ getButtons(
this.isPlaying || this.isPaused,
this.isPaused,
realQueue.length > 1,
) ]
});
}
public async errMsg(reason: string) {
if (!this.msg) return null;
await this.msg.edit({
content: this.msg.content,
embeds: [ client.mkembed({
title: "오류발생!!",
description: reason,
color: "DarkRed",
}) ],
files: [],
components: [ getButtons(
false,
false,
this.queue.length > 1,
) ]
});
this.errorTimer = setTimeout(() => {
this.errorTimer = undefined;
this.playNext();
}, DelayAfterErrMs);
return null;
}
}

View File

@@ -0,0 +1,25 @@
import { ChatInputCommandInteraction, Collection } from "discord.js";
import { readdirSync } from "node:fs";
import { Command } from "../types/Command";
import { COMMAND_PATH, COMMANDS_PATH } from "../utils/Config";
export class Handler {
public commands: Collection<string, Command> = new Collection();
public coolDown: Map<string, number> = new Map();
public constructor() {
const commandFiles = readdirSync(COMMANDS_PATH);
for (const commandFile of commandFiles) {
const command = new (require(COMMAND_PATH(commandFile)).default)() as Command;
this.commands.set(command.metaData.name, command);
}
}
public runCommand(interaction: ChatInputCommandInteraction) {
const commandName = interaction.commandName;
const command = this.commands.get(commandName);
if (!command) return;
if (command.slashRun) command.slashRun(interaction);
}
}

View File

@@ -0,0 +1,83 @@
import { Connectors, LoadType, Shoukaku } from "shoukaku";
import { Client } from "discord.js";
import { GuildPlayer } from "./GuildPlayer";
import { Config } from "../utils/Config";
import { Logger } from "../utils/Logger";
import { parseLink } from "../utils/music/Url";
import { shuffle } from "../utils/Shuffle";
import { Spotify } from "../utils/api/Spotify";
import { YoutubeMusic } from "../utils/api/YoutubeMusic";
export class LavalinkManager {
public shoukaku: Shoukaku;
private players: Map<string, GuildPlayer> = new Map();
constructor(readonly client: Client) {
this.shoukaku = new Shoukaku(new Connectors.DiscordJS(client), [{
name: "tkrmagid-Lavalink-Server",
url: `${Config.lavalink.host}:${Config.lavalink.port}`,
auth: Config.lavalink.pw,
secure: false,
}], {
moveOnDisconnect: true,
reconnectTries: 3,
});
this.shoukaku.on("ready", (name) => {
Logger.ready(`[LavalinkManager] 노드 [${name}] 연결 완료!`);
});
this.shoukaku.on("error", (name, err) => {
Logger.error(`[LavalinkManager] 노드 [${name}] 에러: ${String(err)}`);
});
}
getPlayer(guildId: string) {
return this.players.get(guildId);
}
addPlayer(guildId: string, player: GuildPlayer) {
this.players.set(guildId, player);
}
delPlayer(guildId: string) {
this.players.delete(guildId);
}
async search(guildId: string, query: string, userId: string, player?: GuildPlayer) {
const node = this.shoukaku.options.nodeResolver(this.shoukaku.nodes);
if (!node) {
this.delPlayer(guildId);
throw new ReferenceError(`[LavalinkManager] lavalink node is missing`);
}
player = player ?? this.getPlayer(guildId);
if (!player) return;
const { isUrl, text, flags } = parseLink(query.trim());
let searchText = isUrl ? text : (await Spotify.getSearchUrl(text)) ?? (await YoutubeMusic.getSearchUrl(text)) ?? `ytsearch:${text}`;
if (flags.has('y')) searchText = (await YoutubeMusic.getSearchUrl(text)) ?? `ytsearch:${text}`;
if (searchText.startsWith("ytsearch") && !flags.has('o')) searchText += " Topic";
const result = await node.rest.resolve(searchText);
if (!result || result.loadType === LoadType.EMPTY || result.loadType === LoadType.ERROR) {
if (result?.loadType === LoadType.ERROR) Logger.error(`[LavalinkManager] loadtype ERROR: ${result.data.message}`);
// 노래 못찾았을때
player.errMsg(`노래를 찾을수 없습니다.`);
return;
}
if (result.loadType === LoadType.PLAYLIST) {
player.addTracks(
flags?.has("s") ? shuffle(result.data.tracks) : result.data.tracks,
userId
);
return;
}
if (result.loadType === LoadType.TRACK) {
player.addTrack(result.data, userId);
return;
}
if (result.loadType === LoadType.SEARCH) {
if (result.data.length === 0) {
player.errMsg(`노래를 찾을수 없습니다.`);
return;
}
player.addTrack(result.data[0], userId);
return;
}
}
}

View File

@@ -0,0 +1,77 @@
import { Redis } from "ioredis";
import { Config } from "../utils/Config";
import { Logger } from "../utils/Logger";
import { YoutubeMusic } from "../utils/api/YoutubeMusic";
import { Spotify } from "../utils/api/Spotify";
export class RedisClient {
public pub: Redis = new Redis({ host: Config.redis.host, port: Config.redis.port });
public sub: Redis = new Redis({ host: Config.redis.host, port: Config.redis.port });
constructor() {
this.pub.on("connect", () => {
Logger.ready(`[Redis Pub] 연결 완료 (말하는 입)`);
});
this.sub.on("connect", () => {
Logger.ready(`[Redis Sub] 연결 완료 (듣는 귀)`);
});
this.sub.subscribe("site-bot", (err, count) => {
if (err) return Logger.error(`[Redis Sub] 구독 실패: ${err.message}`);
Logger.log(`[Redis Sub] 'bot-commands' 채널 구독 중... (현재 구독 채널 수: ${count})`);
});
this.sub.on("message", async (ch, msg) => {
if (ch !== "site-bot") return;
Logger.log(`[Redis Sub] [Message] 수신: {\n 채널: ${ch}\n 내용: ${msg}\n}`);
try {
const data = JSON.parse(msg) as { action: "search"; requestId: string; [key: string]: any; };
if (data.action === "search") {
const resultKey = `search:result:${data.requestId}`;
const results = await Spotify.getSearchFull(data.query) ?? await YoutubeMusic.getSearchFull(data.query) ?? [];
await this.pub.setex(resultKey, 60, JSON.stringify(results));
Logger.log(`[Redis Pub] [setex] 결과 저장: (${resultKey})`);
}
} catch (err) {
Logger.error(`명령어 처리 중 에러: ${String(err)}`);
}
});
this.pub.on("error", (err) => {
Logger.error(`[Redis Pub] [Error] ${err.message}`);
});
this.sub.on("error", (err) => {
Logger.error(`[Redis Sub] [Error] ${err.message}`);
});
}
public publishState(event: string, data: any) {
const payload = JSON.stringify({
event,
timestamp: Date.now(),
...data,
});
this.pub.publish("bot-site", payload);
Logger.log(`[Redis Pub] bot -> site 전송: ${event}`);
}
public runTest() {
Logger.debug(`[Redis Test] 3초 뒤에 테스트 통신 시작...`);
setTimeout(() => {
// 1. 봇 -> 사이트(웹) 방향 전송 테스트
this.publishState("TRACK_START", {
author: "테스트",
title: "제목",
duration: 196000,
});
// 2. 사이트(웹) -> 봇 방향 수신 테스트 (가짜 명령을 쏴서 스스로 수신하는지 확인)
setTimeout(() => {
const mockCommand = JSON.stringify({ action: "skip", userId: "12345" });
// 테스트를 위해 본인이 site-bot 채널로 발행해 봅니다.
this.pub.publish("site-bot", mockCommand);
}, 1000);
}, 3000);
}
}

134
bot/src/commands/channel.ts Normal file
View File

@@ -0,0 +1,134 @@
import { ApplicationCommandOptionType, ChannelType, ChatInputApplicationCommandData, ChatInputCommandInteraction, EmbedBuilder, Guild } from "discord.js";
import { client } from "../index";
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";
/** channel 명령어 */
export default class implements Command {
/** 해당 명령어 설명 */
name = "channel";
visible = true;
aliases: string[] = [];
description: string = "채널 생성 또는 연결";
metaData: ChatInputApplicationCommandData = {
name: this.name,
description: this.description,
options: [
{
type: ApplicationCommandOptionType.Subcommand,
name: "make",
description: "채널 만들기",
},
{
type: ApplicationCommandOptionType.Subcommand,
name: "register",
description: "기존 채팅채널 등록",
options: [
{
type: ApplicationCommandOptionType.Channel,
name: "channel",
description: "등록할 채널 (선택)",
channel_types: [ChannelType.GuildText],
},
{
type: ApplicationCommandOptionType.String,
name: "channel_id",
description: "채널 ID 또는 #멘션 (선택)",
},
]
},
],
};
/** 실행되는 부분 */
async slashRun(interaction: ChatInputCommandInteraction) {
const cmd = interaction.options.getSubcommand();
if (cmd === "make") {
await interaction.editReply({ embeds: [ await this.channelCreate(interaction.guild) ] });
return;
}
if (cmd === "register") {
const channel = interaction.options.getChannel("channel");
const channelId = interaction.options.getString("channel_id");
await interaction.editReply({ embeds: [ await channelRegister(interaction.guild, channel?.id || channelId) ] });
return;
}
}
async channelCreate(guild: Guild | null): Promise<EmbedBuilder> {
if (!guild) return client.mkembed({
title: "guild를 가져올수 없습니다.",
color: "DarkRed",
});
const channel = await guild.channels.create({
name: "MUSIC채널",
type: ChannelType.GuildText,
topic: "채팅에 노래제목 또는 주소를 입력해 사용하세요.",
}).catch(() => null);
if (!channel) return client.mkembed({
title: "채널 생성 오류",
color: "DarkRed"
});
return channelRegister(guild, channel.id);
}
}
export async function channelRegister(guild: Guild | null, channelId: string | null): Promise<EmbedBuilder> {
if (!guild) return client.mkembed({
title: "guild를 가져올수 없습니다.",
color: "DarkRed",
});
if (!channelId) return client.mkembed({
title: "채널 아이디 가져오기 오류",
color: "DarkRed",
});
const channel = guild.channels.cache.get(channelId.replace(/\<|\#|\!|\>/g,"").trim());
if (!channel?.id) return client.mkembed({
title: `${channelId} 채널 가져오기 오류`,
color: "DarkRed",
});
if (channel.type !== ChannelType.GuildText) return client.mkembed({
title: `<#${channelId}> 채팅 채널이 아닙니다.`,
color: "DarkRed",
});
await clearAllMsg(channel);
const msg = await channel.send({
content: default_content,
embeds: [ default_embed(guild.id) ],
components: [ getButtons() ],
files: [ default_image ],
}).catch((err) => {
console.error(err);
return null;
});
if (!msg) return client.mkembed({
title: "메세지 생성 오류",
color: "DarkRed"
});
if (!DB.guild.get(guild.id)) DB.guild.set({
id: guild.id,
name: guild.name,
channel_id: "",
msg_id: "",
options: { recommend: false }
});
const gdb = DB.guild.get(guild.id);
if (!gdb) return client.mkembed({
title: "DB 생성 오류",
color: "DarkRed",
});
if (!DB.guild.update({
...gdb,
channel_id: channel.id,
msg_id: msg.id,
})) return client.mkembed({
title: "DB 등록 오류",
color: "DarkRed",
});
return client.mkembed({
title: "채널 생성/등록 완료",
description: `채널: <#${channel.id}>`,
});
}

View File

@@ -0,0 +1,27 @@
import { client } from "../index";
import { Command } from "../types/Command";
import { Message, ChatInputApplicationCommandData, ChatInputCommandInteraction, ChannelType } from "discord.js";
/** example 명령어 */
export default class implements Command {
/** 해당 명령어 설명 */
name = "example";
visible = false;
aliases: string[] = ["예시"];
description: string = "예시 명령어";
metaData: ChatInputApplicationCommandData = {
name: this.name,
description: this.description,
};
/** 실행되는 부분 */
async slashRun(interaction: ChatInputCommandInteraction) {
await interaction.editReply({ embeds: [ client.mkembed({
title: "예시 명령어",
}) ] });
}
async messageRun(message: Message) {
if (message.channel?.type !== ChannelType.GuildText) return;
return await message.channel.send({ content: "예시 명령어" }).then(m => client.msgDelete(m, 5));
}
}

105
bot/src/commands/help.ts Normal file
View File

@@ -0,0 +1,105 @@
import { client, handler } from "../index";
import { Command } from "../types/Command";
import { CacheType, Message, ActionRowBuilder, EmbedBuilder, ChatInputApplicationCommandData, StringSelectMenuBuilder, StringSelectMenuInteraction, ApplicationCommandOptionType, ChannelType, ChatInputCommandInteraction } from "discord.js";
/** help 명령어 */
export default class implements Command {
/** 해당 명령어 설명 */
name = "help";
visible = true;
aliases: string[] = ["도움말"];
description: string = "명령어 확인";
metaData: ChatInputApplicationCommandData = {
name: this.name,
description: this.description,
};
/** 실행되는 부분 */
async slashRun(interaction: ChatInputCommandInteraction) {
await interaction.editReply(this.getHelp());
}
async messageRun(message: Message) {
if (message.channel?.type !== ChannelType.GuildText) return;
return await message.channel.send(this.getHelp()).then(m => client.msgDelete(m, 5));
}
async menuRun(interaction: StringSelectMenuInteraction<CacheType>, args: string[]) {
const command = handler.commands.get(args[0]);
var embed = client.mkembed({});
var embed2: EmbedBuilder | undefined = undefined;
if (command) {
embed.setTitle(`\` /${args[0]} 도움말 \``)
.setDescription(`이름: ${args[0]}\n설명: ${command.description}`);
embed2 = helpData(command.metaData.name, command.metaData);
} else {
embed.setTitle(`\` ${args[0]} 도움말 \``)
.setDescription(`명령어를 찾을수 없습니다.`)
.setFooter({ text: `도움말: /help` })
.setColor('DarkRed');
}
if (embed2) {
await interaction.editReply({ embeds: [ embed, embed2 ] });
return;
}
await interaction.editReply({ embeds: [ embed ] });
}
getHelp(): { embeds: EmbedBuilder[], components: ActionRowBuilder<StringSelectMenuBuilder>[] } {
const slashcmdembed = client.mkembed({
title: `\` slash (/) 도움말 \``,
description: `명령어\n명령어 설명`
});
const msgcmdembed = client.mkembed({
title: `\` 기본 (${client.prefix}) 도움말 \``,
description: `명령어 [같은 명령어]\n명령어 설명`,
footer: { text: `PREFIX: ${client.prefix}` }
});
let cmdlist: { label: string, description: string, value: string }[] = [];
handler.commands.forEach((cmd) => {
if (cmd.slashRun && cmd.visible) {
cmdlist.push({ label: `/${cmd.name}`, description: `${cmd.description}`, value: `${cmd.name}` });
slashcmdembed.addFields([{ name: `**/${cmd.name}**`, value: `${cmd.description}`, inline: true }]);
}
});
handler.commands.forEach((cmd) => {
if (cmd.messageRun && cmd.visible) {
msgcmdembed.addFields([{ name: `**${client.prefix}${cmd.name}${cmd.aliases ? ` [ ${cmd.aliases} ]` : ""}**`, value: `${cmd.description}`, inline: true }]);
}
});
const rowhelp = client.mkembed({
title: '\` 명령어 상세보기 \`',
description: `명령어의 자세한 내용은\n아래의 선택박스에서 선택해\n확인할수있습니다.`,
footer: { text: '여러번 가능' }
});
const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
new StringSelectMenuBuilder()
.setCustomId('help')
.setPlaceholder('명령어를 선택해주세요.')
.addOptions(cmdlist)
);
return { embeds: [ slashcmdembed, msgcmdembed, rowhelp ], components: [ row ] };
}
}
function helpData(name: string, metadata: ChatInputApplicationCommandData): EmbedBuilder | undefined {
var text = "";
metadata.options?.forEach((opt) => {
text += `/${name} ${opt.name}`;
if (opt.type === ApplicationCommandOptionType.Subcommand && opt.options) {
if (opt.options.length > 1) {
text = "";
opt.options.forEach((opt2) => {
text += `/${name} ${opt.name} [${opt2.type}] : ${opt.description}\n`;
});
} else {
text += ` [${opt.options[0].type}] : ${opt.description}\n`;
}
} else {
text += ` : ${opt.description}\n`;
}
});
if (!text || text.length == 0) return undefined;
return client.mkembed({
title: `\` ${name} 명령어 \``,
description: text,
});
}

93
bot/src/commands/join.ts Normal file
View File

@@ -0,0 +1,93 @@
import { Message, ChatInputApplicationCommandData, Guild, ChatInputCommandInteraction, EmbedBuilder, ApplicationCommandOptionType, ChannelType } from "discord.js";
import { client, lavalinkManager } from "../index";
import { Command } from "../types/Command";
import { GuildPlayer } from "../classes/GuildPlayer";
import { getTextChannelAndMsg } from "../utils/music/Channel";
/** join 명령어 */
export default class implements Command {
/** 해당 명령어 설명 */
name = "join";
visible = true;
aliases: string[] = [];
description: string = "음성채널 참가";
metaData: ChatInputApplicationCommandData = {
name: this.name,
description: this.description,
options: [
{
type: ApplicationCommandOptionType.Channel,
name: "channel",
description: "등록할 채널 (선택)",
channel_types: [ChannelType.GuildVoice],
},
{
type: ApplicationCommandOptionType.String,
name: "channel_id",
description: "채널 ID 또는 #멘션 (선택)",
},
]
};
/** 실행되는 부분 */
async slashRun(interaction: ChatInputCommandInteraction) {
const channel = interaction.options.getChannel("channel");
const channelId = interaction.options.getString("channel_id");
await interaction.editReply({ embeds: [ (await channelJoin(interaction.guild, channel?.id || channelId)).embed ] });
return;
}
async messageRun(message: Message) {
if (message.channel?.type !== ChannelType.GuildText) return;
return;
// return await message.channel.send({ content: "예시 명령어" }).then(m => client.msgDelete(m, 5));
}
}
export async function channelJoin(guild: Guild | null, voiceChannelId: string | null): Promise<{
embed: EmbedBuilder;
player?: GuildPlayer;
}> {
if (!guild) return { embed: client.mkembed({
title: "guild를 가져올수 없습니다.",
color: "DarkRed",
}) };
const { channel, msg, reason } = await getTextChannelAndMsg(guild);
if (reason || !channel || !msg) return { embed: client.mkembed({
title: reason ?? "오류발생",
color: "DarkRed",
}) };
if (!voiceChannelId) return { embed: client.mkembed({
title: "채널 아이디 가져오기 오류",
color: "DarkRed",
}) };
const voiceChannel = guild.channels.cache.get(voiceChannelId.replace(/\<|\#|\!|\>/g,"").trim());
if (!voiceChannel?.id) return { embed: client.mkembed({
title: `${voiceChannelId} 채널 가져오기 오류`,
color: "DarkRed",
}) };
if (voiceChannel.type !== ChannelType.GuildVoice) return { embed: client.mkembed({
title: `<#${voiceChannelId}> 음성 채널이 아닙니다.`,
color: "DarkRed",
}) };
let player = lavalinkManager.getPlayer(guild.id);
if (player) return { embed: client.mkembed({ title: `이미 <#${player.voiceChannelId} 참가중입니다.` }), player };
player = new GuildPlayer(
guild,
await lavalinkManager.shoukaku.joinVoiceChannel({
guildId: guild.id,
channelId: voiceChannel.id,
shardId: guild.shardId,
deaf: true,
mute: false,
}),
voiceChannel.id,
channel,
msg,
);
lavalinkManager.addPlayer(guild.id, player);
return { embed: client.mkembed({
title: `<#${voiceChannelId}> 참가 완료`,
}), player };
}

49
bot/src/commands/ping.ts Normal file
View File

@@ -0,0 +1,49 @@
import { client } from "../index";
import { Command } from "../types/Command";
import { Message, ChatInputApplicationCommandData, ChatInputCommandInteraction, ChannelType } from "discord.js";
/** ping 명령어 */
export default class implements Command {
/** 해당 명령어 설명 */
name = "ping";
visible = true;
aliases: string[] = ["핑"];
description: string = "기본 명령어";
metaData: ChatInputApplicationCommandData = {
name: this.name,
description: this.description,
};
/** 실행되는 부분 */
async slashRun(interaction: ChatInputCommandInteraction) {
const msg = await interaction.editReply({ embeds: [ client.mkembed({
title: "핑...",
description: "계산중...",
}) ] });
if (!(msg instanceof Message)) {
await interaction.editReply({ embeds: [ client.mkembed({
title: "오류",
description: `메시지 타입 불일치`,
color: "DarkRed",
}) ] });
return;
}
const ping = msg.createdTimestamp - interaction.createdTimestamp;
await interaction.editReply({ embeds: [ client.mkembed({
title: "퐁!!",
description: `${ping}ms`,
}) ] });
}
async messageRun(message: Message) {
if (message.channel?.type !== ChannelType.GuildText) return;
const msg = await message.channel.send({ embeds: [ client.mkembed({
title: "핑...",
description: "계산중...",
}) ] });
const ping = msg.createdTimestamp - message.createdTimestamp;
return await msg.edit({ embeds: [ client.mkembed({
title: "퐁!!",
description: `${ping}ms`,
}) ] }).then(m => client.msgDelete(m, 1));
}
}

83
bot/src/commands/seek.ts Normal file
View File

@@ -0,0 +1,83 @@
import { client, lavalinkManager } from "../index";
import { Command } from "../types/Command";
import { Message, ChatInputApplicationCommandData, ChannelType, ChatInputCommandInteraction, ApplicationCommandOptionType, Guild } from "discord.js";
const formatTime = (sec: number): string => {
const s = Math.max(0, Math.floor(sec));
return [
Math.floor(s / 3600),
Math.floor((s % 3600) / 60),
s % 60,
].map(v => v.toString().padStart(2,"0")).join(":");
}
/** seek 명령어 */
export default class implements Command {
/** 해당 명령어 설명 */
name = "seek";
visible = true;
aliases: string[] = [];
description: string = "특정시간부터 재생";
metaData: ChatInputApplicationCommandData = {
name: this.name,
description: this.description,
options: [
{
type: ApplicationCommandOptionType.Integer,
name: "hour",
description: "시간",
min_value: 0,
},
{
type: ApplicationCommandOptionType.Integer,
name: "min",
description: "분",
min_value: 0,
},
{
type: ApplicationCommandOptionType.Integer,
name: "sec",
description: "초",
min_value: 0,
},
]
};
/** 실행되는 부분 */
async slashRun(interaction: ChatInputCommandInteraction) {
const hour = interaction.options.getInteger("hour");
const min = interaction.options.getInteger("min");
const sec = interaction.options.getInteger("sec");
await interaction.editReply({ embeds: [ await this.playSeek(interaction.guild, hour, min, sec) ] });
}
async messageRun(message: Message) {
if (message.channel?.type !== ChannelType.GuildText) return;
return await message.channel.send({ content: "예시 명령어" }).then(m => client.msgDelete(m, 5));
}
async playSeek(guild: Guild | null, hour: number | null, min: number | null, sec: number | null) {
if (!guild) return client.mkembed({
title: "guild를 가져올수 없습니다.",
color: "DarkRed",
});
const seek = ((hour ?? 0)*3600 + (min ?? 0)*60 + (sec ?? 0))*1000;
const player = lavalinkManager.getPlayer(guild.id);
if (!player) return client.mkembed({
title: "player를 가져올수 없습니다.",
color: "DarkRed",
});
if (!player.isPlaying || !player.nowTrack) return client.mkembed({
title: "현재 노래가 재생되지 않았습니다.",
color: "DarkRed",
});
if (player.nowTrack.info.length <= seek) return client.mkembed({
title: "노래 총시간이 더 작습니다.",
description: `총시간: ${formatTime(player.nowTrack.info.length)}\n설정시간: ${formatTime(seek)}`,
color: "DarkRed",
});
player.seek(seek);
return client.mkembed({
title: "재생 설정 성공",
});
}
}

View File

@@ -0,0 +1,40 @@
import { Config } from "../utils/Config";
import { client, handler, Redis } from "../index";
import { Logger } from "../utils/Logger";
import { DB } from "../utils/Database";
import { channelRegister } from "../commands/channel";
export const clientReady = async () => {
if (!client.user) return;
Logger.ready(`봇 활성화! ${client.user.username}`);
client.user.setActivity({
name: "MUSIC 봇",
});
reloadMsg();
const guildIds = client.guilds.cache.map(guild => guild.id);
Redis.pub.set("bot-guilds", JSON.stringify(guildIds));
Logger.info(`[Redis Pub] bot-guilds 설정 완료: [${guildIds.join(",")}]`);
if (!Config.dev) return;
try {
const body = Array.from(handler.commands.values().filter(cmd => cmd.visible).map(cmd => cmd.metaData));
await client.application?.commands.set(body, Config.guildId);
Logger.ready(`DEV 길드(${Config.guildId}) 슬래시 등록: ${body.length}`);
} catch (err) {
throw err instanceof Error ? err : new Error(String(err));
}
}
async function reloadMsg() {
const guilds = DB.guild.all();
if (guilds.length === 0) return;
for (const gdb of guilds.filter(g => g.channel_id)) {
Logger.info(`${gdb.name} : 채널 재연동`);
const guild = client.guilds.cache.get(gdb.id) ?? null;
const embed = await channelRegister(guild, gdb.channel_id);
Logger.info(`${guild?.name ?? gdb.name} : ${embed.data.title ?? "오류"}`);
}
}

View File

@@ -0,0 +1,8 @@
import { client, Redis } from "../index";
import { Logger } from "../utils/Logger";
export const guildCreate = async () => {
const guildIds = client.guilds.cache.map(guild => guild.id);
Redis.pub.set("bot-guilds", JSON.stringify(guildIds));
Logger.info(`[Redis Pub] bot-guilds 수정 완료: [${guildIds.join(",")}]`);
}

View File

@@ -0,0 +1,8 @@
import { client, Redis } from "../index";
import { Logger } from "../utils/Logger";
export const guildDelete = async () => {
const guildIds = client.guilds.cache.map(guild => guild.id);
Redis.pub.set("bot-guilds", JSON.stringify(guildIds));
Logger.info(`[Redis Pub] bot-guilds 수정 완료: [${guildIds.join(",")}]`);
}

13
bot/src/events/index.ts Normal file
View File

@@ -0,0 +1,13 @@
import { clientReady } from "./clientReady";
import { guildCreate } from "./guildCreate";
import { interactionCreate } from "./interactionCreate";
import { messageCreate } from "./messageCreate";
// import { voiceStateUpdate } from "./voiceStateUpdate";
export const onEvents = {
clientReady,
guildCreate,
interactionCreate,
messageCreate,
// voiceStateUpdate,
}

View File

@@ -0,0 +1,36 @@
import { Interaction, MessageFlags } from "discord.js";
import { handler } from "../index";
import { buttonInteraction } from "../utils/music/Button";
export const interactionCreate = async (interaction: Interaction) => {
if (interaction.isStringSelectMenu()) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch(() => {});
const commandName = interaction.customId;
const args = interaction.values;
const command = handler.commands.get(commandName);
if (command?.menuRun) return await command.menuRun(interaction, args);
}
if (interaction.isButton()) {
const args = interaction.customId.split("-");
if (!args || args.length === 0) return;
if (args[0] === "music") return buttonInteraction(interaction, args.slice(1));
await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch(() => {});
const key = args.shift();
if (!key) return;
const command = handler.commands.get(key);
if (command?.buttonRun) return command.buttonRun(interaction, args);
}
if (!interaction.isChatInputCommand()) return;
/**
* 명령어 친사람만 보이게 설정
* flags: MessageFlags.Ephemeral
*/
await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch(() => {});
handler.runCommand(interaction);
}

View File

@@ -0,0 +1,83 @@
import { ChannelType, Message } from "discord.js";
import { client, handler, lavalinkManager } from "../index";
import { Config } from "../utils/Config";
import { Logger } from "../utils/Logger";
import { DB } from "../utils/Database";
import { getVoiceChannel } from "../utils/music/Channel";
import { channelJoin } from "../commands/join";
const cmdErr = (message: Message, commandName: string | undefined | null): void => {
if (!commandName) return;
if (message.channel.type !== ChannelType.GuildText) return;
message.channel.send({ embeds: [ client.mkembed({
description: `\` ${commandName} \` 이라는 명령어를 찾을 수 없습니다.`,
footer: { text: `${Config.prefix}help를 입력해 명령어를 확인해주세요.` },
color: "DarkRed",
}) ] }).then(m => client.msgDelete(m, 1));
}
export const messageCreate = async (message: Message): Promise<void> => {
if (message.author.bot || message.channel.type === ChannelType.DM) return;
if (!message.content.startsWith(client.prefix)) {
handleMessage(message);
return;
}
const content = message.content.slice(client.prefix.length).trim();
const args = content.split(/ +/g);
const commandName = args.shift()?.toLocaleLowerCase() || "";
const command = handler.commands.get(commandName) || handler.commands.find((cmd) => cmd.aliases.includes(commandName));
try {
if (!command || !command.messageRun) {
if (!commandName || commandName.replace(/\;| +/g,"").length === 0) return;
cmdErr(message, commandName);
return client.msgDelete(message, 0, true);
}
command.messageRun(message, args);
client.msgDelete(message, 0, true);
} catch(err: any) {
if (Config.debug) Logger.error(err);
cmdErr(message, commandName);
return client.msgDelete(message, 0, true);
}
}
async function handleMessage(message: Message): Promise<void> {
if (!message.guild?.id) return;
if (!message.member?.user?.id) return;
if (message.channel.type !== ChannelType.GuildText) return;
const gdb = DB.guild.get(message.guild.id);
if (!gdb?.channel_id) return;
if (gdb.channel_id !== message.channel.id) return;
let player = lavalinkManager.getPlayer(message.guild.id);
const voiceChannel = getVoiceChannel(message.member);
if (!player) {
if (!voiceChannel) {
message.channel.send({ embeds: [ client.mkembed({
author: {
name: message.member.nickname ?? message.member.user.displayName,
iconURL: message.member.displayAvatarURL({}) ?? undefined,
},
title: "음성채널을 찾을수 없습니다.",
description: "음성채널에 들어가서 사용해주세요.",
color: "DarkRed",
}) ] }).then(m => client.msgDelete(m, 1));
return client.msgDelete(message, 100, true);
}
player = (await channelJoin(message.guild, voiceChannel.id)).player;
}
if (!player) {
message.channel.send({ embeds: [ client.mkembed({
author: {
name: message.member.nickname ?? message.member.user.displayName,
iconURL: message.member.displayAvatarURL({}) ?? undefined,
},
title: "세션을 찾을수 없습니다.",
description: "다시 시도해주세요.",
color: "DarkRed",
}) ] }).then(m => client.msgDelete(m, 1));
return client.msgDelete(message, 100, true);
}
lavalinkManager.search(message.guild.id, message.content.trim(), message.member.user.id, player);
return client.msgDelete(message, 100, true);
}

View File

@@ -0,0 +1,5 @@
import { VoiceState } from "discord.js";
import { client } from "../index";
export const voiceStateUpdate = async (oldState: VoiceState, newState: VoiceState): Promise<void> => {
}

16
bot/src/index.ts Normal file
View File

@@ -0,0 +1,16 @@
import { BotClient } from "./classes/BotClient";
import { LavalinkManager } from "./classes/LavalinkManager";
import { Handler } from "./classes/Handler";
import { onEvents } from "./events";
import { RedisClient } from "./classes/RedisClient";
export const client = new BotClient();
export const lavalinkManager = new LavalinkManager(client);
export const handler = new Handler();
export const Redis = new RedisClient();
for (const eventName of Object.keys(onEvents) as (keyof typeof onEvents)[]) {
client.onEvent(eventName, onEvents[eventName]);
}
client.start();

22
bot/src/types/Command.d.ts vendored Normal file
View File

@@ -0,0 +1,22 @@
import { ButtonInteraction, ChatInputApplicationCommandData, ChatInputChatInputCommandInteraction, Message, StringSelectMenuInteraction } from "discord.js";
export interface Command {
/** 메세지 이름 */
name: string;
/** help 목록 노출 여부 */
visible: boolean;
/** 메세지 기반 별칭 */
aliases: string[];
/** 간단 설명 */
description: string;
/**
* 등록 메타: JSON 변환된 바디
* (빌드 시 toJSON()해서 REST 등록에 사용)
*/
metaData: RESTPostAPIChatInputApplicationCommandsJSONBody;
slashRun?: (args: ChatInputChatInputCommandInteraction) => Promise<void>;
messageRun?: (message: Message, args: string[]) => Promise<void>;
menuRun?: (interaction: StringSelectMenuInteraction, args: string[]) => Promise<void>;
buttonRun?: (interaction: ButtonInteraction, args: string[]) => Promise<void>;
}

7
bot/src/types/Track.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
export interface SongItem {
title: string;
artist: string;
videoId: string;
thumbnail: string; // 썸네일 URL
duration: number; // 재생시간 (ms 단위)
}

View File

@@ -0,0 +1,42 @@
export enum Cookies {
/** 세션, 재생 추적, 봇 감지 */
YSC = "YSC",
/** 세션 ID, 로그인 상태 인증 */
SID = "SID",
/** 로그인 세션 서명용 */
HSID = "HSID",
/** 계정 관련 보조인증 */
SSID = "SSID",
/** 유튜브 로그인 정보 */
LOGIN_INFO = "LOGIN_INFO",
/** 보안된 인증 토큰 (YouTube용) */
SAPISID = "SAPISID",
/** API 인증용 쿠키 */
APISID = "APISID",
/** 유튜브 요청 시 인증 */
Secure1PAPISID = "__Secure-1PAPISID",
/** 유튜브 요청 시 인증 (써드파티) */
Secure3PAPISID = "__Secure-3PAPISID",
/** 광고 및 인증 관련 */
Secure1PSID = "__Secure-1PSID",
/** 광고 및 인증 관련 (써드파티) */
Secure3PSID = "__Secure-3PSID",
/** 무결성 검증, 세션 탈취 방지 쿠키 */
Secure1PSIDCC = "__Secure-1PSIDCC",
/** 무결성 검증, 세션 탈취 방지 쿠키 (써드파티) */
Secure3PSIDCC = "__Secure-3PSIDCC",
/** 인증 세션 타임스탬프 쿠키 */
Secure1PSIDTS = "__Secure-1PSIDTS",
/** 인증 세션 타임스탬프 쿠키 (써드파티) */
Secure3PSIDTS = "__Secure-3PSIDTS",
/** 쿠키 위조 방지 (보안 강화) */
SIDCC = "SIDCC",
/** 유튜브 설정 정보 */
VISITOR_INFO1_LIVE = "VISITOR_INFO1_LIVE",
/** 선호 설정 저장 (VISITOR_INFO1_LIVE 같이쓰면 좋음) */
PREF = "PREF",
/** 개인정보/쿠키 사용에 대한 동의여부 */
CONSENT = "CONSENT",
/** 보안/동의 상태 저장 */
SOCS = "SOCS",
};

89
bot/src/utils/Config.ts Normal file
View File

@@ -0,0 +1,89 @@
import "dotenv/config";
import { ColorResolvable, Colors } from "discord.js";
import { join } from "node:path";
export const Config = {
debug: process.env.DEBUG?.trim()?.toLocaleLowerCase() === "true",
dev: process.env.DEV?.trim()?.toLocaleLowerCase() === "true",
_prefix: process.env.PREFIX?.trim(),
get prefix() {
if (!this._prefix) throw new ReferenceError("PREFIX is missing");
return this._prefix;
},
_appId: process.env.APPID?.trim(),
get appId() {
if (!this._appId) throw new ReferenceError("APPID is missing");
return this._appId;
},
_token: process.env.TOKEN?.trim(),
get token() {
if (!this._token) throw new ReferenceError("TOKEN is missing");
return this._token;
},
_dbPath: process.env.DBPATH?.trim(),
get dbPath() {
if (!this._dbPath) throw new ReferenceError("DBPATH is missing");
return this._dbPath;
},
_guildId: process.env.GUILDID?.trim(),
get guildId() {
if (!this._guildId) throw new ReferenceError("GUILDID is missing");
return this._guildId;
},
_embedColor: process.env.EMBEDCOLOR?.trim(),
get embedColor() {
if (!this._embedColor) throw new ReferenceError("EMBEDCOLOR is missing");
const list = Object.keys(Colors);
if (!list.includes(this._embedColor)) throw new TypeError(`EMBEDCOLOR TYPE ERROR: ${list.join(",")}`);
return this._embedColor as ColorResolvable;
},
_lavalink: {
host: process.env.LAVALINK_HOST?.trim(),
port: process.env.LAVALINK_PORT?.trim(),
pw: process.env.LAVALINK_PW?.trim(),
},
get lavalink() {
if (!this._lavalink.host) throw new ReferenceError("LAVALINK_HOST is missing");
if (!this._lavalink.port) throw new ReferenceError("LAVALINK_PORT is missing");
if (!this._lavalink.pw) throw new ReferenceError("LAVALINK_PW is missing");
const port = Number(this._lavalink.port!);
if (isNaN(port)) throw new TypeError("LAVALINK_PORT must be a number");
return {
host: this._lavalink.host,
port: port,
pw: this._lavalink.pw,
}
},
_youtube_cookie: process.env.YOUTUBE_COOKIE?.trim(),
get youtube_cookie() {
if (!this._youtube_cookie) throw new ReferenceError("YOUTUBE_COOKIE is missing");
if (this._youtube_cookie.startsWith('"')) this._youtube_cookie = this._youtube_cookie.slice(1,-1);
return this._youtube_cookie;
},
_redis: {
host: process.env.REDIS_HOST?.trim(),
port: process.env.REDIS_PORT?.trim(),
},
get redis() {
if (!this._redis.host) throw new ReferenceError("REDIS_HOST is missing");
if (!this._redis.port) throw new ReferenceError("REDIS_PORT is missing");
const port = Number(this._redis.port!);
if (isNaN(port)) throw new TypeError("REDIS_PORT must be a number");
return {
host: this._redis.host,
port: port,
};
}
};
export const COMMANDS_PATH = join(__dirname, "..", "commands");
export const COMMAND_PATH = (commandFile: string) => join(COMMANDS_PATH, commandFile);

117
bot/src/utils/Database.ts Normal file
View File

@@ -0,0 +1,117 @@
import Database from "better-sqlite3";
import { Config } from "./Config";
import { join } from "node:path";
import { readFileSync } from "node:fs";
import { GuildRow, GuildType } from "../../db/db";
import { Logger } from "./Logger";
const database = new Database(Config.dbPath);
const schemaPath = join(process.cwd(), "db/schema.sql");
const schema = readFileSync(schemaPath, "utf-8");
database.exec(schema);
Logger.ready("DB 활성화!");
const stmt = {
guild: {
// 전체
all: database.prepare("SELECT * FROM guilds"),
// 가져오기
get: database.prepare("SELECT * FROM guilds WHERE ID = ?"),
// 추가
insert: (data: GuildRow) => {
const keys = Object.keys(data);
if (keys.length === 0) throw new Error("insert: 키1개는 있어야함");
return database.prepare(`INSERT INTO guilds (${
keys.map(k => `"${k}"`).join(", ")
}) VALUES (${
keys.map(k => `@${k}`).join(", ")
})`).run(data);
},
// 수정
update: (data: GuildRow) => {
const keys = Object.keys(data).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 = {
guild: {
all() {
return stmt.guild.all.all() as GuildType[];
},
get(id: string) {
const row = stmt.guild.get.get(id) as GuildRow | undefined;
if (!row) return undefined;
return { ...row, options: JSON.parse(row.options) } as GuildType;
},
set(data: GuildType) {
try {
stmt.guild.insert({ ...data, options: JSON.stringify(data.options) });
return true;
} catch (err) {
Logger.error(String(err));
return false;
}
},
update(data: GuildType) {
try {
stmt.guild.update({ ...data, options: JSON.stringify(data.options) });
return true;
} catch (err) {
Logger.error(String(err));
return false;
}
},
},
// 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;
// }
// },
// },
};

38
bot/src/utils/Logger.ts Normal file
View File

@@ -0,0 +1,38 @@
import colors from "colors/safe";
export const Timestamp = () => {
const Now = new Date();
Now.setHours(Now.getHours() + 9);
return Now.toISOString().replace('T', ' ').substring(0, 19).slice(2);
}
type logType = "log" | "info" | "warn" | "error" | "debug" | "ready" | "slash";
const log = (content: string, type: logType) => {
const timestamp = colors.white(`[${Timestamp()}]`);
switch (type) {
case "log":
return console.log(`${colors.gray("[LOG]")} ${timestamp} ${content}`);
case "info":
return console.log(`${colors.cyan("[INFO]")} ${timestamp} ${content}`);
case "warn":
return console.log(`${colors.yellow("[WARN]")} ${timestamp} ${content}`);
case "error":
return console.log(`${colors.red("[ERROR]")} ${timestamp} ${content}`);
case "debug":
return console.log(`${colors.magenta("[DEBUG]")} ${timestamp} ${content}`);
case "ready":
return console.log(`${colors.green("[READY]")} ${timestamp} ${content}`);
default:
throw new TypeError("Logger 타입이 올바르지 않습니다.");
}
};
export const Logger = {
log: (content: string) => log(content, "log"),
warn: (content: string) => log(content, "warn"),
error: (content: string) => log(content, "error"),
debug: (content: string) => log(content, "debug"),
info: (content: string) => log(content, "info"),
ready: (content: string) => log(content, "ready")
}

View File

@@ -0,0 +1,130 @@
/// <reference types="spotify-api" />
import "dotenv/config";
import { Logger } from "../Logger";
import { SongItem } from "../../types/Track";
const SPOTIFY_CLIENTID = process.env.SPOTIFY_CLIENTID?.trim() ?? "";
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 searchCache = new Map<string, string>();
type TOKENER_RESPONSE = {
clientId: string;
accessToken: string;
accessTokenExpirationTimestampMs: number;
isAnonymous: boolean;
_notes: string;
};
export const Spotify = {
_token: {
cached: "",
expiresAt: 0,
},
async getTokenByTokener(): Promise<string | null> {
if (this._token.cached && Date.now() < this._token.expiresAt) return this._token.cached;
try {
const response = await fetch(TOKENR_URL);
if (!response.ok) throw new Error(`토크너 서버 응답 에러: ${response.status}`);
const jsonData = await response.json() as TOKENER_RESPONSE;
this._token.cached = jsonData.accessToken;
this._token.expiresAt = jsonData.accessTokenExpirationTimestampMs;
return jsonData.accessToken;
} catch (err) {
Logger.error(`토크너 접근 실패: ${err}`);
return null;
}
},
async getToken(): Promise<string | null> {
try {
const response = await fetch(SPOTIFY_TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: SPOTIFY_CLIENTID,
client_secret: SPOTIFY_SECRET,
}),
});
const textData = await response.text();
if (!response.ok) {
Logger.error(`스포티파이 정식 토큰 발급 에러 (${response.status}): ${textData}`);
return null;
}
const jsonData = JSON.parse(textData);
this._token.cached = jsonData.access_token;
// expires_in은 초(초당 1000ms) 단위입니다. 만료 1분 전(60000ms)에 미리 갱신되게 세팅!
this._token.expiresAt = Date.now() + (jsonData.expires_in * 1000) - 60000;
Logger.info("🟢 [Spotify] 공식 API 키로 정식 토큰 발급 완료!");
return jsonData.access_token;
} catch (err) {
Logger.error(`스포티파이 토큰 요청 실패: ${err}`);
return null;
}
},
async getSearchFull(query: string): Promise<SongItem[]> {
try {
const token = await this.getToken();
if (!token) return [];
const response = await fetch(
`${SPOTIFY_API_URL}/search?q=${encodeURIComponent(query)}&type=track&market=KR&limit=1`,
{
headers: {
Authorization: `Bearer ${token}`,
"Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7",
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
},
},
);
const textData = await response.text();
if (!response.ok) {
if (response.status === 429) {
Logger.warn("⚠️ 스포티파이 요청 제한(Rate Limit)에 걸렸습니다.");
} else {
Logger.error(`스포티파이 API 에러 (${response.status}): ${textData}`);
}
return [];
}
const data = JSON.parse(textData) as SpotifyApi.TrackSearchResponse;
if (!data.tracks || data.tracks.items.length === 0) return [];
return data.tracks.items.map((track) => ({
videoId: track.id,
title: track.name,
artist: track.artists.map(artist => artist.name).join(", "),
duration: track.duration_ms,
thumbnail: track.album.images[2]?.url,
}));
} catch (err) {
Logger.error(`스포티파이 검색 실패: ${err}`);
return [];
}
},
async getSearchUrl(query: string): Promise<string | null> {
const lowerQuery = query.toLocaleLowerCase().trim();
if (searchCache.has(lowerQuery)) return searchCache.get(lowerQuery) ?? null;
const track = (await this.getSearchFull(query) ?? [])?.[0];
const url = track?.videoId ? `https://open.spotify.com/track/${track.videoId}` : null;
if (url) searchCache.set(lowerQuery, url);
return url;
}
}

View File

@@ -0,0 +1,216 @@
import { fetch, ProxyAgent } from "undici";
import crypto from "node:crypto";
import { Cookies } from "../../types/Youtube_Cookie";
import { Config } from "../Config";
import { SongItem } from "../../types/Track";
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 searchCache = new Map<string, string>();
// 🌟 클래스 외부에 둘 상수 및 유틸리티 함수들 (내부에서만 사용됨)
const defaultCookies: Cookies[] = [
Cookies.SAPISID, // 해시 생성용 씨앗 1
Cookies.Secure3PAPISID, // 해시 생성용 씨앗 2
Cookies.SID, // 메인 세션 신분증
Cookies.Secure3PSID, // API용 메인 신분증 (필수)
Cookies.Secure1PSIDTS, // 세션 신선도 검증 타임스탬프
Cookies.PREF // 지역/언어 설정
];
/**
* "MM:SS" 또는 "HH:MM:SS" 형태의 문자열을 밀리초(ms)로 변환합니다.
*/
function parseDurationToMs(timeStr: string): number {
if (!timeStr) return 0;
const parts = timeStr.split(':').reverse(); // [초, 분, 시간]
let ms = 0;
if (parts[0]) ms += parseInt(parts[0], 10) * 1000;
if (parts[1]) ms += parseInt(parts[1], 10) * 60 * 1000;
if (parts[2]) ms += parseInt(parts[2], 10) * 60 * 60 * 1000;
return isNaN(ms) ? 0 : ms;
}
// 🌟 Spotify.ts와 동일하게 Youtube 객체 하나로 모든 기능을 묶어서 export 합니다.
export const YoutubeMusic = {
getCookieJson(keys: Cookies[] = defaultCookies, blocks?: Cookies[]): { [key: string]: string } {
const cookies = Object.fromEntries(
Config.youtube_cookie
.split(";")
.map(v => v.trim())
.filter(v => v.includes("="))
.map(v => {
const [k, ...vs] = v.split("=");
return [k.trim(), vs.join("=").trim()];
})
);
// 한국 맞춤형 지역/설정 강제 주입
cookies["PREF"] = customPREF;
const allows = keys.filter(
(k) => k in cookies && !(blocks ?? []).includes(k)
);
const missing = keys.filter((k) => !(k in cookies) && !(blocks ?? []).includes(k));
if (missing.length > 0) {
console.log("현재 입력된 쿠키 키 목록:", Object.keys(cookies));
throw new Error(`❌ 필수 인증 쿠키가 누락되었습니다: ${missing.join(", ")}`);
}
return Object.fromEntries(allows.map((k) => [k, cookies[k]]));
},
getCookie(keys: Cookies[] = defaultCookies, blocks?: Cookies[]): string {
// 내부 메서드 호출 시 this 사용
const json = this.getCookieJson(keys, blocks);
return Object.entries(json).map(([k, v]) => `${k}=${v}`).join("; ");
},
getAuthorization() {
// 내부 메서드 호출 시 this 사용
const sapisid = this.getCookieJson([Cookies.Secure3PAPISID])["__Secure-3PAPISID"];
const t = Math.floor(Date.now() / 1000);
const h = crypto.createHash("sha1").update(`${t} ${sapisid} ${ORIGIN}`).digest("hex");
return `SAPISIDHASH ${t}_${h} SAPISID1PHASH ${t}_${h} SAPISID3PHASH ${t}_${h}`;
},
/**
* 완벽한 쿠키 인증과 서명(SAPISIDHASH)을 사용하여 유튜브 뮤직 검색을 수행합니다.
*/
async getSearchFull(query: string): Promise<SongItem[]> {
console.log(`🔍 [Auth-Cookie Engine] "${query}" 데이터 추출 중 (썸네일, 재생시간 포함)...`);
const url = "https://music.youtube.com/youtubei/v1/search?prettyPrint=false";
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Cookie': this.getCookie(), // this 바인딩 적용
'Authorization': this.getAuthorization(), // this 바인딩 적용
'Origin': 'https://music.youtube.com',
'Referer': `https://music.youtube.com/search?q=${encodeURIComponent(query)}`,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.7680.178 Safari/537.36'
},
body: JSON.stringify({
context: {
client: {
clientName: "WEB_REMIX",
clientVersion: "1.20240320.01.00",
hl: "ko",
gl: "KR"
}
},
query: query,
params: "EgWKAQIIAWoOEAMQBBAQEAkQFRAKEBE="
}),
dispatcher: proxy
});
const data: any = await response.json();
if (data.error) {
throw new Error(`서버 에러 (${data.error.code}): ${data.error.message}`);
}
const results: SongItem[] = [];
const tabs = data.contents?.tabbedSearchResultsRenderer?.tabs || [];
const sections = tabs[0]?.tabRenderer?.content?.sectionListRenderer?.contents || [];
for (const section of sections) {
// 1. 최상단 'Top Result' 카드 파싱
if (section.musicCardShelfRenderer) {
const card = section.musicCardShelfRenderer;
const title = card.title?.runs?.[0]?.text;
const videoId = card.onTap?.watchEndpoint?.videoId || card.title?.runs?.[0]?.navigationEndpoint?.watchEndpoint?.videoId;
let artist = "Unknown Artist";
let durationStr = "";
if (card.subtitle?.runs) {
const validRuns = card.subtitle.runs.map((r: any) => r.text).filter((t: string) => t !== " • " && t !== "노래" && t !== "동영상");
if (validRuns.length > 0) artist = validRuns[0];
const timeMatch = validRuns.find((t: string) => /^\d+:\d+/.test(t));
if (timeMatch) durationStr = timeMatch;
}
const thumbnails = card.thumbnail?.musicThumbnailRenderer?.thumbnail?.thumbnails || [];
const thumbnail = thumbnails.length > 0 ? thumbnails[thumbnails.length - 1].url : "";
if (videoId && title) {
results.push({
title,
artist,
videoId,
thumbnail,
duration: parseDurationToMs(durationStr)
});
}
}
// 2. 일반 검색 결과 목록 파싱
if (section.musicShelfRenderer) {
const items = section.musicShelfRenderer.contents || [];
for (const item of items) {
const track = item.musicResponsiveListItemRenderer;
if (!track) continue;
const videoId = track.playlistItemData?.videoId;
const title = track.flexColumns?.[0]?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.[0]?.text;
let artist = "Unknown Artist";
const subtitleRuns = track.flexColumns?.[1]?.musicResponsiveListItemFlexColumnRenderer?.text?.runs || [];
if (subtitleRuns.length >= 3) {
artist = subtitleRuns[2]?.text || artist;
} else if (subtitleRuns.length === 1) {
artist = subtitleRuns[0]?.text || artist;
}
let durationStr = track.fixedColumns?.[0]?.musicResponsiveListItemFixedColumnRenderer?.text?.runs?.[0]?.text;
if (!durationStr) {
const timeRun = subtitleRuns.find((r: any) => /^\d+:\d+/.test(r.text));
if (timeRun) durationStr = timeRun.text;
}
const thumbnails = track.thumbnail?.musicThumbnailRenderer?.thumbnail?.thumbnails || [];
const thumbnail = thumbnails.length > 0 ? thumbnails[thumbnails.length - 1].url : "";
if (videoId && title) {
results.push({
title,
artist,
videoId,
thumbnail,
duration: parseDurationToMs(durationStr || "")
});
}
}
}
}
return results || []; // 배열이 비어있을 경우 안전하게 null 반환
} catch (error) {
console.error("❌ getSearchFull 실행 중 에러:", error);
return [];
}
},
async getSearchUrl(query: string): Promise<string | null> {
const lowerQuery = query.toLocaleLowerCase().trim();
if (searchCache.has(lowerQuery)) return searchCache.get(lowerQuery) ?? null;
const video = (await this.getSearchFull(query) ?? [])?.[0];
const url = video?.videoId ? `https://music.youtube.com/watch?v=${video.videoId}` : null;
if (url) searchCache.set(lowerQuery, url);
return url;
}
};

View File

@@ -0,0 +1,39 @@
import { ButtonInteraction, Guild } from "discord.js";
import { lavalinkManager } from "../../index";
import { checkTextChannelAndMsg, getTextChannelAndMsg } from "./Channel";
import { default_content, default_embed, default_image, getButtons } from "./Config";
import { DB } from "../Database";
export const buttonInteraction = (interaction: ButtonInteraction, args: string[]) => {
if (!interaction.guild) return;
let player = lavalinkManager.getPlayer(interaction.guild.id);
if (player) {
if (args[0] === "pause") player.setPause();
if (args[0] === "stop") player.stop();
if (args[0] === "skip") player.skip();
if (args[0] === "shuffle") player.setShuffle();
if (args[0] === "recommend") player.setRecommend();
} else {
if (args[0] === "recommend") buttonRecommend(interaction.guild);
}
return interaction.deferUpdate().catch(() => {});
}
const buttonRecommend = async (guild: Guild) => {
const gdb = DB.guild.get(guild.id);
const change = gdb ? DB.guild.update({...gdb, options: { ...gdb.options, recommend: !gdb.options.recommend }}) : false;
if (!change) return;
const { channel: getChannel, msg: getMsg, reason } = await getTextChannelAndMsg(guild);
if (reason || !getChannel || !getMsg) return;
const { msg, check } = await checkTextChannelAndMsg(guild, getChannel, getMsg);
if (!check) return;
await msg.edit({
content: default_content,
embeds: [ default_embed(guild.id) ],
components: [ getButtons() ],
files: [ default_image ],
}).catch((err) => {
console.error(err);
return null;
});
}

View File

@@ -0,0 +1,54 @@
import { ChannelType, Guild, GuildMember, Message, TextChannel, VoiceChannel } from "discord.js";
import { DB } from "../Database";
import { Config } from "../Config";
import { Logger } from "../Logger";
import { clearAllMsg } from "./Utils";
export const getVoiceChannel = (member: GuildMember): VoiceChannel | null => {
if (member.voice.channel?.type === ChannelType.GuildVoice) return member.voice.channel;
if (member.guild.members.me?.voice.channel?.type === ChannelType.GuildVoice) return member.guild.members.me.voice.channel;
return 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;
return getVoiceChannel(member);
}
export const getTextChannelAndMsg = async (guild: Guild): Promise<{ channel?: TextChannel; msg?: Message; reason?: string; }> => {
const gdb = DB.guild.get(guild.id);
const channelId = gdb?.channel_id;
if (!channelId) return { reason: "DB불러오기 오류" };
const channel = guild.channels.cache.get(channelId);
if (!channel?.id) return { reason: "채팅 채널 가져오기 오류" };
if (channel.type !== ChannelType.GuildText) return { reason: `<#${channelId}> 채팅 채널이 아닙니다.` };
const msgId = gdb?.msg_id;
if (!msgId) return { channel, reason: "DB불러오기 오류" };
const msg = channel.messages.cache.get(msgId);
if (!msg?.id) return { channel, reason: "메세지 가져오기 오류" };
return { channel, msg };
}
export const checkTextChannelAndMsg = async (guild: Guild, getChannel: TextChannel, getMsg: Message): Promise<{ channel: TextChannel; msg: Message; check: boolean; }> => {
const channel = await getChannel.fetch(true).catch((err) => {
if (Config.debug) Logger.error(`checkTextChannelAndMsg (textChannel (fetch)): ${String(err)}`);
return null;
});
if (!channel || channel.type !== ChannelType.GuildText) return { channel: getChannel, msg: getMsg, check: false };
let msg = await getMsg?.fetch(true).catch((err) => {
if (Config.debug) Logger.error(`checkTextChannelAndMsg (msg (fetch)): ${String(err)}`);
return null;
});
if (!msg) {
if (Config.debug) Logger.info(`${guild.name} msg 재생성`);
await clearAllMsg(channel);
msg = await channel.send({ content: "메세지 재생성중..." }).catch((err) => {
if (Config.debug) Logger.error(`checkTextChannelAndMsg (channel (send)): ${String(err)}`);
return null;
});
}
if (!msg) return { channel, msg: getMsg, check: false };
return { channel, msg, check: true };
}

View File

@@ -0,0 +1,73 @@
import { EmbedBuilder, AttachmentBuilder, ButtonStyle, ButtonBuilder, APIMessageComponentEmoji, ActionRowBuilder } from "discord.js";
import { lavalinkManager } from "../../index";
import { Config } from "../Config";
import { DB } from "../Database";
export const default_content = [
"__**대기열 목록:**__",
"음성 채널에 참여한 후 노래제목 혹은 url로 노래를 대기열에 추가하세요.",
].join("\n");
export const default_image = new AttachmentBuilder("./images/default_img.png");
export const default_embed = (guildId: string) => new EmbedBuilder()
.setTitle("**현재 노래가 재생되지 않았습니다.**")
.setImage("attachment://default_img.png")
.setFooter({ text: `PREFIX: ${Config.prefix} | 볼륨: ${lavalinkManager.getPlayer(guildId)?.player.volume ?? 50}${
DB.guild.get(guildId)?.options.recommend ? " | 자동재생: 활성화" : ""
}` })
.setColor(Config.embedColor);
export const loading_embed = new EmbedBuilder()
.setTitle("**재생 준비중...**")
.setImage("attachment://default_img.png")
.setFooter({ text: `PREFIX: ${Config.prefix}` })
.setColor(Config.embedColor);
const btn = (
id: string,
emoji: string | APIMessageComponentEmoji,
style: ButtonStyle,
disabled: boolean = false
) => new ButtonBuilder()
.setCustomId(id)
.setEmoji(typeof emoji === "string" ? { name: emoji } : emoji)
.setStyle(style)
.setDisabled(disabled);
export const getButtons = (
playing?: boolean,
pause?: boolean,
list?: boolean
) => new ActionRowBuilder<ButtonBuilder>().addComponents(...[
btn(
"music-pause",
"⏯️",
pause ? ButtonStyle.Primary : ButtonStyle.Success,
!playing
),
btn(
"music-stop",
"⏹️",
ButtonStyle.Danger,
false
),
btn(
"music-skip",
"⏭️",
ButtonStyle.Secondary,
!playing
),
btn(
"music-shuffle",
"🔀",
ButtonStyle.Secondary,
!(playing && list)
),
btn(
"music-recommend",
{ id: "1035604533532954654", name: "auto" },
ButtonStyle.Secondary,
false
)
]);

View File

@@ -0,0 +1,16 @@
export const parseLink = (text: string): { isUrl: boolean; text: string; flags: Set<string>; } => {
const isUrl = text.trim().startsWith("http");
const parts = text.trim().split(/\s+/);
const flags = new Set<string>();
while (parts.length > 0) {
const last = parts[parts.length-1];
if (/^-[A-Za-z]$/.test(last)) {
flags.add(last[1].toLocaleLowerCase()); // "-s" → "s"
parts.pop();
} else {
break;
}
}
text = parts.join(" ").trim();
return { isUrl, text, flags };
}

View File

@@ -0,0 +1,42 @@
import { Collection, Message, TextChannel } from "discord.js";
import { Logger } from "../Logger";
const sleep = (ms: number) => new Promise(res => setTimeout(res, ms));
export const clearAllMsg = async (channel: TextChannel) => {
Logger.log(`${channel.guild.name} : ${channel.name} 메세지 전부 삭제`);
let fetched: Collection<string, Message>;
do {
fetched = await channel.messages.fetch({ limit: 100 });
if (fetched.size === 0) break;
for (const m of fetched.values()) {
try {
await m.delete();
await sleep(200);
} catch (err: any) {
const retry = (err?.retry_after ?? err?.data?.retry_after ?? err?.rawError?.retry_after) as number | undefined;
if (retry) {
const waitMs = Math.ceil(retry*1000);
Logger.log(`429 레이트리밋 감지: ${waitMs}ms 대기`);
await sleep(waitMs);
try {
await m.delete();
await sleep(200);
} catch (err2) {
Logger.error(`메세지 ${m.id} 삭제 재시도 실패:`+String(err2));
}
} else {
Logger.error(`메세지 ${m.id} 삭제 실패:`+String(err));
await sleep(200);
}
}
}
} while (fetched.size > 0);
Logger.log(`${channel.guild.name} : ${channel.name} 메세지 전부 삭제 완료!`);
}
export const timeFormat = (num: number): string => {
num = Math.floor(num/1000);
if (num < 60) return "00:"+num.toString().padStart(2, '0');
return Math.floor(num/60).toString().padStart(2, '0')+':'+(num%60).toString().padStart(2, '0');
}

15
bot/src/utils/shuffle.ts Normal file
View File

@@ -0,0 +1,15 @@
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;
}
return list;
}
export const shuffle = (list: any[]): any[] => {
for (let z=0; z<5; z++) list = fshuffle(list);
return list;
}

26
bot/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "esnext",
"module": "commonjs",
"rootDir": "./src",
"outDir": "./dist",
"removeComments": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"alwaysStrict": true,
"skipLibCheck": true,
"noImplicitAny": true, // any타입 금지 여부
"strictNullChecks": true, // null, undefined 타입에 이상한 짓 할시 에러내기
"strictFunctionTypes": true, // 함수파라미터 타입체크 강하게
"strictPropertyInitialization": true, // class constructor 작성시 타입체크 강하게
"noImplicitThis": true, // this 키워드가 any 타입일 경우 에러내기
"noUnusedLocals": true, // 쓰지않는 지역변수 있으면 에러내기
"noUnusedParameters": true, // 쓰지않는 파라미터 있으면 에러내기
"noImplicitReturns": true, // 함수에서 return 빼먹으면 에러내기
"noFallthroughCasesInSwitch": true // switch문 이상하면 에러내기
},
"include": [ "src" ]
}