commit 4a3577f8c87954f04222651b58db81bd129fe18f Author: projectdx Date: Sun Feb 22 15:20:31 2026 +0900 feat: external video player flow and discord playback link improvements diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c66a65d --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +DISCORD_TOKEN= +DISCORD_CLIENT_ID= +DISCORD_GUILD_ID= +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/discord_multibot +REDIS_URL=redis://localhost:6379/1 +LAVALINK_NODE_NAME=local +LAVALINK_HOST=127.0.0.1 +LAVALINK_PORT=2333 +LAVALINK_PASSWORD=youshallnotpass +LAVALINK_SECURE=false +GEMINI_API_KEY= +GEMINI_FLASH_MODEL=gemini-2.5-flash +GEMINI_PRO_MODEL=gemini-2.5-pro +DEEPL_API_KEY= +DEEPL_CLI_BIN=deepl +DEEPL_CLI_TIMEOUT_MS=45000 +GDS_DVIEWER_BASE_URL=http://127.0.0.1:9099/gds_dviewer/normal/explorer +GDS_DVIEWER_API_KEY= +GDS_DVIEWER_SOURCE_ID=0 +EXTERNAL_VIDEO_PLAYER_URL= +IINA_LINK_ICON=🎬 +LOG_LEVEL=info diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9145c03 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.env +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..e7364eb --- /dev/null +++ b/README.md @@ -0,0 +1,135 @@ +# Discord Multi Bot (Music / Manage / Notify) + +TypeScript 기반 λ””μŠ€μ½”λ“œ 봇 ν…œν”Œλ¦Ώμž…λ‹ˆλ‹€. μŒμ•… μž¬μƒμ€ Lavalink(Shoukaku)둜 λ™μž‘ν•©λ‹ˆλ‹€. + +## Stack +- TypeScript + Node.js 20+ +- discord.js v14 +- PostgreSQL + Prisma +- Redis + BullMQ +- Lavalink + Shoukaku + +## 1) ν™˜κ²½ λ³€μˆ˜ +```bash +cp .env.example .env +``` + +`.env` μ˜ˆμ‹œ: +```env +DISCORD_TOKEN=... +DISCORD_CLIENT_ID=... +DISCORD_GUILD_ID=... # 개발/ν…ŒμŠ€νŠΈ μ„œλ²„ ID +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/discord_multibot +REDIS_URL=redis://localhost:6379/1 +LAVALINK_NODE_NAME=local +LAVALINK_HOST=127.0.0.1 +LAVALINK_PORT=2333 +LAVALINK_PASSWORD=youshallnotpass +LAVALINK_SECURE=false +LOG_LEVEL=info +``` + +## 2) PostgreSQL μ€€λΉ„ +μ˜ˆμ‹œ: +```bash +docker run -d \ + --name discord-postgres \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_PASSWORD=postgres \ + -e POSTGRES_DB=discord_multibot \ + -p 5432:5432 \ + -v discord_postgres_data:/var/lib/postgresql/data \ + postgres:16 +``` + +## 3) Lavalink μ€€λΉ„ (Docker) +ν”„λ‘œμ νŠΈ λ£¨νŠΈμ—μ„œ: +```bash +docker compose -f docker-compose.lavalink.yml up -d +``` + +μƒνƒœ 확인: +```bash +docker logs -f discord-lavalink +``` + +## 4) μ˜μ‘΄μ„±/DB μ΄ˆκΈ°ν™” +```bash +npm install +npm run prisma:generate +npm run prisma:push +``` + +## 5) μ‹€ν–‰ +```bash +npm run dev +``` + +정상 둜그 μ˜ˆμ‹œ: +- `Guild commands registered` +- `Bot ready` + +## λͺ…λ Ήμ–΄ +### Music (Lavalink) +- `/play query:<검색어 λ˜λŠ” 유튜브 URL>` +- `/queue` +- `/skip` +- `/stop` + +### Manage +- `/warn user: reason:` +- `/warnings user:` + +### Notify +- `/notify_schedule channel: cron: message:` +- `/notify_list` +- `/notify_disable rule_id:` + +### News +- `/news query:` + +### Summarize (Google AI Studio) +- `/summarize url: mode:` + +### Translate +- `/translate text: source: target:` +- μš°μ„ μˆœμœ„: `DeepL API` -> `deepl-cli(web)` -> `Google Web v2` + +## μ‹€μ œ μ‚¬μš© μ˜ˆμ‹œ +1. 봇과 μ‚¬μš©μž λͺ¨λ‘ 같은 μŒμ„± 채널 μž…μž₯ +2. ν…μŠ€νŠΈ μ±„λ„μ—μ„œ: + - `/play query:μ•„μ΄μœ  λ°€νŽΈμ§€` + - `/play query:https://www.youtube.com/watch?v=...` +3. μ œμ–΄: + - `/queue` (λŒ€κΈ°μ—΄ 확인) + - `/skip` (λ‹€μŒ 곑) + - `/stop` (μ •μ§€ + μŒμ„±μ±„λ„ 퇴μž₯) + +4. 접두사 λͺ…λ Ή: + - `!play <검색어 λ˜λŠ” URL>` + - `!queue` + - `!skip` + - `!stop` + - `!λ‰΄μŠ€ [ν‚€μ›Œλ“œ]` + - `!news [keyword]` + - `!μš”μ•½ [auto|fast|quality]` + - `!summarize [auto|fast|quality]` + - `!λ²ˆμ—­ [source->target] <ν…μŠ€νŠΈ>` (κΈ°λ³Έ `auto->ko`) + - `!translate [source->target] ` + - `!ani "제λͺ©"` (gds_dviewer μ• λ‹ˆ 검색) + - `!μ˜ν™” "제λͺ©"` / `!movie "title"` (gds_dviewer μ˜ν™” 검색) + +## μ°Έκ³  +- `REDIS_URL`이 μ—†μœΌλ©΄ BullMQ μ›Œμ»€λŠ” λΉ„ν™œμ„±ν™”λ©λ‹ˆλ‹€. +- YouTube μž¬μƒ μ•ˆμ •μ„±μ€ Lavalink μ„œλ²„ μƒνƒœ/ν”ŒλŸ¬κ·ΈμΈμ— 영ν–₯λ°›μŠ΅λ‹ˆλ‹€. +- μš”μ•½ κΈ°λŠ₯은 `GEMINI_API_KEY`κ°€ ν•„μš”ν•©λ‹ˆλ‹€. +- λ²ˆμ—­ κΈ°λŠ₯은 `DEEPL_API_KEY`κ°€ μ—†μœΌλ©΄ `deepl-cli`λ₯Ό λ¨Όμ € μ‹œλ„ν•˜κ³ , μ‹€νŒ¨ μ‹œ `Google Web v2`λ₯Ό μ‚¬μš©ν•©λ‹ˆλ‹€. +- `deepl-cli` ν…ŒμŠ€νŠΈμš© μ„€μ •: + - `DEEPL_CLI_BIN=deepl` + - `DEEPL_CLI_TIMEOUT_MS=45000` + - pyenv μ‚¬μš© μ‹œ μ˜ˆμ‹œ: `DEEPL_CLI_BIN=/Users/yommi/.pyenv/versions/3.11.0/envs/FF_3.11/bin/deepl` +- `!ani` κΈ°λŠ₯ ν™˜κ²½ λ³€μˆ˜: + - `GDS_DVIEWER_BASE_URL=http://127.0.0.1:9099/gds_dviewer/normal/explorer` + - `GDS_DVIEWER_API_KEY=...` + - `GDS_DVIEWER_SOURCE_ID=0` + - `EXTERNAL_VIDEO_PLAYER_URL=https://your-domain/player/external_video_player.html` (Discord λ…ΈμΆœ URL λΆ„λ¦¬μš©, ꢌμž₯) diff --git a/docker-compose.lavalink.yml b/docker-compose.lavalink.yml new file mode 100644 index 0000000..4b5bb59 --- /dev/null +++ b/docker-compose.lavalink.yml @@ -0,0 +1,10 @@ +services: + lavalink: + image: ghcr.io/lavalink-devs/lavalink:4 + container_name: discord-lavalink + restart: unless-stopped + ports: + - "2333:2333" + volumes: + - ./lavalink/application.yml:/opt/Lavalink/application.yml:ro + - ./lavalink/plugins:/opt/Lavalink/plugins diff --git a/lavalink/application.yml b/lavalink/application.yml new file mode 100644 index 0000000..d8282e2 --- /dev/null +++ b/lavalink/application.yml @@ -0,0 +1,24 @@ +server: + port: 2333 + +lavalink: + server: + password: "youshallnotpass" + sources: + youtube: false + bandcamp: true + soundcloud: true + twitch: true + vimeo: true + http: true + local: false + +plugins: + - dependency: "dev.lavalink.youtube:youtube-plugin:1.17.0" + repository: "https://maven.lavalink.dev/releases" + snapshot: false + +logging: + level: + root: INFO + lavalink: INFO diff --git a/lavalink/plugins/youtube-plugin-1.17.0.jar b/lavalink/plugins/youtube-plugin-1.17.0.jar new file mode 100644 index 0000000..838c6b0 Binary files /dev/null and b/lavalink/plugins/youtube-plugin-1.17.0.jar differ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..dbdf707 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2281 @@ +{ + "name": "discord_multibot", + "version": "0.1.41", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "discord_multibot", + "version": "0.1.41", + "dependencies": { + "@prisma/client": "^6.15.0", + "bullmq": "^5.58.0", + "cheerio": "^1.2.0", + "discord.js": "^14.22.1", + "dotenv": "^16.6.1", + "fast-xml-parser": "^5.3.7", + "ioredis": "^5.8.0", + "pino": "^9.9.0", + "shoukaku": "^4.2.0", + "zod": "^4.1.5" + }, + "devDependencies": { + "@types/node": "^24.3.0", + "pino-pretty": "^13.1.1", + "prisma": "^6.15.0", + "tsx": "^4.20.5", + "typescript": "^5.9.2" + } + }, + "node_modules/@discordjs/builders": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.1.tgz", + "integrity": "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.33", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz", + "integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.1.1", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.3", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.16", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.21.3" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.1", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@ioredis/commands": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz", + "integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==", + "license": "MIT" + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@prisma/client": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.2.tgz", + "integrity": "sha512-gR2EMvfK/aTxsuooaDA32D8v+us/8AAet+C3J1cc04SW35FPdZYgLF+iN4NDLUgAaUGTKdAB0CYenu1TAgGdMg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/config": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.2.tgz", + "integrity": "sha512-kadBGDl+aUswv/zZMk9Mx0C8UZs1kjao8H9/JpI4Wh4SHZaM7zkTwiKn/iFLfRg+XtOAo/Z/c6pAYhijKl0nzQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.18.4", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.2.tgz", + "integrity": "sha512-lFnEZsLdFLmEVCVNdskLDCL8Uup41GDfU0LUfquw+ercJC8ODTuL0WNKgOKmYxCJVvFwf0OuZBzW99DuWmoH2A==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.2.tgz", + "integrity": "sha512-TTkJ8r+uk/uqczX40wb+ODG0E0icVsMgwCTyTHXehaEfb0uo80M9g1aW1tEJrxmFHeOZFXdI2sTA1j1AgcHi4A==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.2", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/fetch-engine": "6.19.2", + "@prisma/get-platform": "6.19.2" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", + "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.2.tgz", + "integrity": "sha512-h4Ff4Pho+SR1S8XerMCC12X//oY2bG3Iug/fUnudfcXEUnIeRiBdXHFdGlGOgQ3HqKgosTEhkZMvGM9tWtYC+Q==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.2", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/get-platform": "6.19.2" + } + }, + "node_modules/@prisma/get-platform": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.2.tgz", + "integrity": "sha512-PGLr06JUSTqIvztJtAzIxOwtWKtJm5WwOG6xpsgD37Rc84FpfUBGLKz65YpJBGtkRQGXTYEFie7pYALocC3MtA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.2" + } + }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", + "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/bullmq": { + "version": "5.70.0", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.70.0.tgz", + "integrity": "sha512-HlBSEJqG7MJ97+d/N/8rtGOcpisjGP3WD/zaXZia0hsmckJqAPTVWN6Yfw32FVfVSUVVInZQ2nUgMd2zCRghKg==", + "license": "MIT", + "dependencies": { + "cron-parser": "4.9.0", + "ioredis": "5.9.2", + "msgpackr": "1.11.5", + "node-abort-controller": "3.1.1", + "semver": "7.7.4", + "tslib": "2.8.1", + "uuid": "11.1.0" + } + }, + "node_modules/bullmq/node_modules/ioredis": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.2.tgz", + "integrity": "sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.0", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cheerio/node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/discord-api-types": { + "version": "0.38.40", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.40.tgz", + "integrity": "sha512-P/His8cotqZgQqrt+hzrocp9L8RhQQz1GkrCnC9TMJ8Uw2q0tg8YyqJyGULxhXn/8kxHETN4IppmOv+P2m82lQ==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/discord.js": { + "version": "14.25.1", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz", + "integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/builders": "^1.13.0", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.0", + "@discordjs/util": "^1.2.0", + "@discordjs/ws": "^1.2.3", + "@sapphire/snowflake": "3.5.3", + "discord-api-types": "^0.38.33", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.21.3" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/effect": { + "version": "3.18.4", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", + "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/fast-copy": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz", + "integrity": "sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-xml-parser": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.7.tgz", + "integrity": "sha512-JzVLro9NQv92pOM/jTCR6mHlJh2FGwtomH8ZQjhFj/R29P2Fnj38OgPJVtcvYw6SuKClhgYuwUZf5b3rd8u2mA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^2.1.2" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ioredis": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.3.tgz", + "integrity": "sha512-VI5tMCdeoxZWU5vjHWsiE/Su76JGhBvWF1MJnV9ZtGltHk9BmD48oDq8Tj8haZ85aceXZMxLNDQZRVo5QKNgXA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.0", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "license": "MIT" + }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/magic-bytes.js": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", + "license": "MIT" + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/msgpackr": { + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz", + "integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT" + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/nypm": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", + "integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.2.0", + "pathe": "^2.0.3", + "tinyexec": "^1.0.2" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nypm/node_modules/citty": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.1.tgz", + "integrity": "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "13.1.3", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz", + "integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^4.0.0", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^4.0.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^5.0.2" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-pretty/node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/prisma": { + "version": "6.19.2", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.2.tgz", + "integrity": "sha512-XTKeKxtQElcq3U9/jHyxSPgiRgeYDKxWTPOf6NkXA0dNj5j40MfEsZkMbyNpwDWCUv7YBFUl7I2VK/6ALbmhEg==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "6.19.2", + "@prisma/engines": "6.19.2" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shoukaku": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/shoukaku/-/shoukaku-4.2.0.tgz", + "integrity": "sha512-3vPQLG484cZ1/2nd4ERRs6XESvGhvD8jZiB0STcpmTtnH6A/6ZcT3iYl00RoU1PZhC7TTrrvCYk1ca+KJjkoYw==", + "license": "MIT", + "dependencies": { + "ws": "^8.18.0" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strnum": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", + "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "6.21.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", + "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5585a76 --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "discord_multibot", + "version": "0.1.41", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc -p tsconfig.json && node scripts/bump-patch.mjs", + "build:check": "tsc -p tsconfig.json", + "start": "node dist/index.js", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate dev", + "prisma:push": "prisma db push", + "version:minor": "node scripts/bump-patch.mjs minor", + "version:major": "node scripts/bump-patch.mjs major" + }, + "dependencies": { + "@prisma/client": "^6.15.0", + "bullmq": "^5.58.0", + "cheerio": "^1.2.0", + "discord.js": "^14.22.1", + "dotenv": "^16.6.1", + "fast-xml-parser": "^5.3.7", + "ioredis": "^5.8.0", + "pino": "^9.9.0", + "shoukaku": "^4.2.0", + "zod": "^4.1.5" + }, + "devDependencies": { + "@types/node": "^24.3.0", + "pino-pretty": "^13.1.1", + "prisma": "^6.15.0", + "tsx": "^4.20.5", + "typescript": "^5.9.2" + } +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..7f88462 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,40 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model GuildConfig { + id String @id @default(cuid()) + guildId String @unique + logChannelId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Warning { + id String @id @default(cuid()) + guildId String + userId String + moderatorId String + reason String + createdAt DateTime @default(now()) + + @@index([guildId, userId]) +} + +model NotificationRule { + id String @id @default(cuid()) + guildId String + channelId String + cronExpr String + message String + enabled Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([guildId, enabled]) +} diff --git a/scripts/bump-patch.mjs b/scripts/bump-patch.mjs new file mode 100644 index 0000000..5fc3536 --- /dev/null +++ b/scripts/bump-patch.mjs @@ -0,0 +1,45 @@ +import fs from "node:fs"; +import path from "node:path"; + +const root = process.cwd(); +const pkgPath = path.join(root, "package.json"); +const lockPath = path.join(root, "package-lock.json"); + +const target = String(process.argv[2] || "patch").toLowerCase(); +if (!["patch", "minor", "major"].includes(target)) { + console.error(`[version] unsupported target: ${target}`); + process.exit(1); +} + +function bump(version, mode) { + const parts = String(version || "0.0.0").split(".").map((v) => Number(v) || 0); + const [major, minor, patch] = [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0]; + if (mode === "major") return `${major + 1}.0.0`; + if (mode === "minor") return `${major}.${minor + 1}.0`; + return `${major}.${minor}.${patch + 1}`; +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function writeJson(filePath, obj) { + fs.writeFileSync(filePath, `${JSON.stringify(obj, null, 2)}\n`, "utf8"); +} + +const pkg = readJson(pkgPath); +const current = String(pkg.version || "0.0.0"); +const next = bump(current, target); +pkg.version = next; +writeJson(pkgPath, pkg); + +if (fs.existsSync(lockPath)) { + const lock = readJson(lockPath); + lock.version = next; + if (lock.packages && lock.packages[""]) { + lock.packages[""].version = next; + } + writeJson(lockPath, lock); +} + +console.log(`[version] ${current} -> ${next} (${target})`); diff --git a/src/commands/index.ts b/src/commands/index.ts new file mode 100644 index 0000000..a9a3fdb --- /dev/null +++ b/src/commands/index.ts @@ -0,0 +1,30 @@ +import type { CommandModule } from "../discord/types.js"; +import { playCommand } from "./music/play.js"; +import { queueCommand } from "./music/queue.js"; +import { skipCommand } from "./music/skip.js"; +import { stopCommand } from "./music/stop.js"; +import { warnCommand } from "./manage/warn.js"; +import { warningsCommand } from "./manage/warnings.js"; +import { scheduleCommand } from "./notify/schedule.js"; +import { notifyListCommand } from "./notify/list.js"; +import { notifyDisableCommand } from "./notify/disable.js"; +import { newsCommand } from "./info/news.js"; +import { summarizeCommand } from "./info/summarize.js"; +import { translateCommand } from "./info/translate.js"; + +export const commands: CommandModule[] = [ + playCommand, + queueCommand, + skipCommand, + stopCommand, + warnCommand, + warningsCommand, + scheduleCommand, + notifyListCommand, + notifyDisableCommand, + newsCommand, + summarizeCommand, + translateCommand, +]; + +export const commandMap = new Map(commands.map((c) => [c.data.name, c])); diff --git a/src/commands/info/news.ts b/src/commands/info/news.ts new file mode 100644 index 0000000..68c7407 --- /dev/null +++ b/src/commands/info/news.ts @@ -0,0 +1,33 @@ +import { SlashCommandBuilder } from "discord.js"; +import type { CommandModule } from "../../discord/types.js"; +import { newsService } from "../../services/news/news-service.js"; + +export const newsCommand: CommandModule = { + data: new SlashCommandBuilder() + .setName("news") + .setDescription("ꡬ글 λ‰΄μŠ€ μ΅œμ‹  10개λ₯Ό λ³΄μ—¬μ€λ‹ˆλ‹€") + .addStringOption((opt) => + opt + .setName("query") + .setDescription("검색 ν‚€μ›Œλ“œ (μƒλž΅ μ‹œ 전체 λ‰΄μŠ€)") + .setRequired(false), + ), + async execute(interaction) { + const query = interaction.options.getString("query") || ""; + await interaction.deferReply(); + + try { + const items = await newsService.fetchGoogleNews(query, 10); + const title = query ? `ꡬ글 λ‰΄μŠ€: ${query}` : "ꡬ글 λ‰΄μŠ€: μ΅œμ‹ "; + const chunks = newsService.toDiscordMessageChunks(title, items, 1900); + await interaction.editReply({ content: chunks[0], allowedMentions: { parse: [] } }); + for (let i = 1; i < chunks.length; i += 1) { + await interaction.followUp({ content: chunks[i], allowedMentions: { parse: [] } }); + } + } catch (error) { + await interaction.editReply( + `λ‰΄μŠ€ 쑰회 μ‹€νŒ¨: ${error instanceof Error ? error.message : "μ•Œ 수 μ—†λŠ” 였λ₯˜"}`, + ); + } + }, +}; diff --git a/src/commands/info/summarize.ts b/src/commands/info/summarize.ts new file mode 100644 index 0000000..aa1f9ea --- /dev/null +++ b/src/commands/info/summarize.ts @@ -0,0 +1,88 @@ +import { SlashCommandBuilder } from "discord.js"; +import type { CommandModule } from "../../discord/types.js"; +import { + summarizeService, + type SummarizeEngine, + type SummarizeMode, +} from "../../services/summarize/summarize-service.js"; + +function chunkText(text: string, maxLen = 1800): string[] { + const chunks: string[] = []; + let rest = String(text || "").trim(); + while (rest.length > maxLen) { + const cut = rest.lastIndexOf("\n", maxLen); + const idx = cut > 200 ? cut : maxLen; + chunks.push(rest.slice(0, idx).trim()); + rest = rest.slice(idx).trim(); + } + if (rest) chunks.push(rest); + return chunks; +} + +export const summarizeCommand: CommandModule = { + data: new SlashCommandBuilder() + .setName("summarize") + .setDescription("μ›ΉνŽ˜μ΄μ§€λ₯Ό μš”μ•½ν•©λ‹ˆλ‹€") + .addStringOption((opt) => opt.setName("url").setDescription("μš”μ•½ν•  URL").setRequired(true)) + .addStringOption((opt) => + opt + .setName("mode") + .setDescription("λͺ¨λΈ 선택 λͺ¨λ“œ") + .setRequired(false) + .addChoices( + { name: "auto", value: "auto" }, + { name: "fast", value: "fast" }, + { name: "quality", value: "quality" }, + ), + ) + .addStringOption((opt) => + opt + .setName("engine") + .setDescription("μš”μ•½ μ—”μ§„") + .setRequired(false) + .addChoices( + { name: "ai", value: "ai" }, + { name: "basic", value: "basic" }, + { name: "both", value: "both" }, + ), + ), + async execute(interaction) { + const url = interaction.options.getString("url", true); + const mode = (interaction.options.getString("mode") || "auto") as SummarizeMode; + const engine = (interaction.options.getString("engine") || "basic") as SummarizeEngine | "both"; + + await interaction.deferReply(); + try { + const aiResult = + engine === "ai" || engine === "both" + ? await summarizeService.summarizeUrl(url, mode, "ai") + : null; + const basicResult = + engine === "basic" || engine === "both" + ? await summarizeService.summarizeUrl(url, mode, "basic") + : null; + + const ref = aiResult || basicResult; + const sections: string[] = []; + if (aiResult) { + sections.push(`**[AI μš”μ•½]** model=\`${aiResult.model}\` call#${aiResult.callNo}\n${aiResult.summary}`); + } + if (basicResult) { + sections.push( + `**[λΉ„AI μš”μ•½]** model=\`${basicResult.model}\` call#${basicResult.callNo}\n${basicResult.summary}`, + ); + } + + const header = `**μš”μ•½ μ™„λ£Œ**\n제λͺ©: ${ref?.title || "N/A"}\n원문: <${ref?.sourceUrl || url}>`; + const chunks = chunkText(`${header}\n\n${sections.join("\n\n")}`, 1800); + await interaction.editReply(chunks[0]); + for (let i = 1; i < chunks.length; i += 1) { + await interaction.followUp(chunks[i]); + } + } catch (error) { + await interaction.editReply( + `μš”μ•½ μ‹€νŒ¨: ${error instanceof Error ? error.message : "μ•Œ 수 μ—†λŠ” 였λ₯˜"}`, + ); + } + }, +}; diff --git a/src/commands/info/translate.ts b/src/commands/info/translate.ts new file mode 100644 index 0000000..d2ea707 --- /dev/null +++ b/src/commands/info/translate.ts @@ -0,0 +1,38 @@ +import { SlashCommandBuilder } from "discord.js"; +import type { CommandModule } from "../../discord/types.js"; +import { translateService } from "../../services/translate/translate-service.js"; + +export const translateCommand: CommandModule = { + data: new SlashCommandBuilder() + .setName("translate") + .setDescription("ν…μŠ€νŠΈλ₯Ό λ²ˆμ—­ν•©λ‹ˆλ‹€ (DeepL μš°μ„ , μ‹€νŒ¨ μ‹œ Google Web v2)") + .addStringOption((opt) => opt.setName("text").setDescription("λ²ˆμ—­ν•  ν…μŠ€νŠΈ").setRequired(true)) + .addStringOption((opt) => + opt + .setName("source") + .setDescription("원문 μ–Έμ–΄ (예: auto, ja, en)") + .setRequired(false), + ) + .addStringOption((opt) => + opt + .setName("target") + .setDescription("λͺ©ν‘œ μ–Έμ–΄ (예: ko, en, ja)") + .setRequired(false), + ), + async execute(interaction) { + const text = interaction.options.getString("text", true); + const source = interaction.options.getString("source") || "auto"; + const target = interaction.options.getString("target") || "ko"; + + await interaction.deferReply(); + try { + const result = await translateService.translate(text, source, target); + const header = `**λ²ˆμ—­ μ™„λ£Œ** engine=\`${result.engine}\` chars=${result.inputChars} chunks=${result.chunkCount}`; + await interaction.editReply(`${header}\n\n${result.translatedText}`); + } catch (error) { + await interaction.editReply( + `λ²ˆμ—­ μ‹€νŒ¨: ${error instanceof Error ? error.message : "μ•Œ 수 μ—†λŠ” 였λ₯˜"}`, + ); + } + }, +}; diff --git a/src/commands/manage/warn.ts b/src/commands/manage/warn.ts new file mode 100644 index 0000000..e55345b --- /dev/null +++ b/src/commands/manage/warn.ts @@ -0,0 +1,24 @@ +import { PermissionFlagsBits, SlashCommandBuilder } from "discord.js"; +import type { CommandModule } from "../../discord/types.js"; +import { moderationService } from "../../services/moderation/moderation-service.js"; + +export const warnCommand: CommandModule = { + data: new SlashCommandBuilder() + .setName("warn") + .setDescription("μ‚¬μš©μžμ—κ²Œ κ²½κ³ λ₯Ό λΆ€μ—¬ν•©λ‹ˆλ‹€") + .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers) + .addUserOption((opt) => opt.setName("user").setDescription("λŒ€μƒ μœ μ €").setRequired(true)) + .addStringOption((opt) => opt.setName("reason").setDescription("μ‚¬μœ ").setRequired(true)), + async execute(interaction) { + if (!interaction.guildId) return; + const user = interaction.options.getUser("user", true); + const reason = interaction.options.getString("reason", true); + const row = await moderationService.warnUser({ + guildId: interaction.guildId, + userId: user.id, + moderatorId: interaction.user.id, + reason, + }); + await interaction.reply(`κ²½κ³  등둝 μ™„λ£Œ (#${row.id}) <@${user.id}> - ${reason}`); + }, +}; diff --git a/src/commands/manage/warnings.ts b/src/commands/manage/warnings.ts new file mode 100644 index 0000000..70ca8fd --- /dev/null +++ b/src/commands/manage/warnings.ts @@ -0,0 +1,21 @@ +import { SlashCommandBuilder } from "discord.js"; +import type { CommandModule } from "../../discord/types.js"; +import { moderationService } from "../../services/moderation/moderation-service.js"; + +export const warningsCommand: CommandModule = { + data: new SlashCommandBuilder() + .setName("warnings") + .setDescription("μ‚¬μš©μž κ²½κ³  내역을 ν™•μΈν•©λ‹ˆλ‹€") + .addUserOption((opt) => opt.setName("user").setDescription("λŒ€μƒ μœ μ €").setRequired(true)), + async execute(interaction) { + if (!interaction.guildId) return; + const user = interaction.options.getUser("user", true); + const rows = await moderationService.getWarnings(interaction.guildId, user.id); + if (rows.length === 0) { + await interaction.reply("κ²½κ³  내역이 μ—†μŠ΅λ‹ˆλ‹€."); + return; + } + const text = rows.map((r, i) => `${i + 1}. ${r.reason} (${r.createdAt.toISOString().slice(0, 10)})`).join("\n"); + await interaction.reply(`κ²½κ³  λ‚΄μ—­ <@${user.id}>:\n${text}`); + }, +}; diff --git a/src/commands/music/play.ts b/src/commands/music/play.ts new file mode 100644 index 0000000..6f40a18 --- /dev/null +++ b/src/commands/music/play.ts @@ -0,0 +1,23 @@ +import { SlashCommandBuilder } from "discord.js"; +import type { CommandModule } from "../../discord/types.js"; +import { musicPlayer } from "../../services/music/music-player.js"; + +export const playCommand: CommandModule = { + data: new SlashCommandBuilder() + .setName("play") + .setDescription("유튜브 URL λ˜λŠ” 검색어λ₯Ό μž¬μƒν•©λ‹ˆλ‹€") + .addStringOption((opt) => opt.setName("query").setDescription("URL λ˜λŠ” 검색어").setRequired(true)), + async execute(interaction) { + if (!interaction.guildId) return; + const query = interaction.options.getString("query", true); + await interaction.deferReply(); + try { + const message = await musicPlayer.enqueueFromInteraction(interaction, query); + await interaction.editReply(message); + } catch (error) { + await interaction.editReply( + `μž¬μƒ μ‹€νŒ¨: ${error instanceof Error ? error.message : "μ•Œ 수 μ—†λŠ” 였λ₯˜"}`, + ); + } + }, +}; diff --git a/src/commands/music/queue.ts b/src/commands/music/queue.ts new file mode 100644 index 0000000..a8c4c38 --- /dev/null +++ b/src/commands/music/queue.ts @@ -0,0 +1,17 @@ +import { SlashCommandBuilder } from "discord.js"; +import type { CommandModule } from "../../discord/types.js"; +import { musicPlayer } from "../../services/music/music-player.js"; + +export const queueCommand: CommandModule = { + data: new SlashCommandBuilder().setName("queue").setDescription("ν˜„μž¬ μŒμ•… λŒ€κΈ°μ—΄μ„ λ³΄μ—¬μ€λ‹ˆλ‹€"), + async execute(interaction) { + if (!interaction.guildId) return; + const queue = musicPlayer.list(interaction.guildId); + if (queue.length === 0) { + await interaction.reply("λŒ€κΈ°μ—΄μ΄ λΉ„μ–΄ μžˆμŠ΅λ‹ˆλ‹€."); + return; + } + const lines = queue.slice(0, 10); + await interaction.reply(`ν˜„μž¬ λŒ€κΈ°μ—΄:\n${lines.join("\n")}`); + }, +}; diff --git a/src/commands/music/skip.ts b/src/commands/music/skip.ts new file mode 100644 index 0000000..84910c6 --- /dev/null +++ b/src/commands/music/skip.ts @@ -0,0 +1,19 @@ +import { SlashCommandBuilder } from "discord.js"; +import type { CommandModule } from "../../discord/types.js"; +import { musicPlayer } from "../../services/music/music-player.js"; + +export const skipCommand: CommandModule = { + data: new SlashCommandBuilder().setName("skip").setDescription("ν˜„μž¬ νŠΈλž™μ„ μŠ€ν‚΅ν•©λ‹ˆλ‹€"), + async execute(interaction) { + if (!interaction.guildId) return; + try { + const message = await musicPlayer.skip(interaction.guildId); + await interaction.reply(message); + } catch (error) { + await interaction.reply({ + content: `μŠ€ν‚΅ μ‹€νŒ¨: ${error instanceof Error ? error.message : "μ•Œ 수 μ—†λŠ” 였λ₯˜"}`, + ephemeral: true, + }); + } + }, +}; diff --git a/src/commands/music/stop.ts b/src/commands/music/stop.ts new file mode 100644 index 0000000..81f6485 --- /dev/null +++ b/src/commands/music/stop.ts @@ -0,0 +1,19 @@ +import { SlashCommandBuilder } from "discord.js"; +import type { CommandModule } from "../../discord/types.js"; +import { musicPlayer } from "../../services/music/music-player.js"; + +export const stopCommand: CommandModule = { + data: new SlashCommandBuilder().setName("stop").setDescription("λŒ€κΈ°μ—΄μ„ λΉ„μ›λ‹ˆλ‹€"), + async execute(interaction) { + if (!interaction.guildId) return; + try { + const message = await musicPlayer.stop(interaction.guildId); + await interaction.reply(message); + } catch (error) { + await interaction.reply({ + content: `쀑지 μ‹€νŒ¨: ${error instanceof Error ? error.message : "μ•Œ 수 μ—†λŠ” 였λ₯˜"}`, + ephemeral: true, + }); + } + }, +}; diff --git a/src/commands/notify/disable.ts b/src/commands/notify/disable.ts new file mode 100644 index 0000000..44b4acb --- /dev/null +++ b/src/commands/notify/disable.ts @@ -0,0 +1,16 @@ +import { SlashCommandBuilder } from "discord.js"; +import type { CommandModule } from "../../discord/types.js"; +import { notifyService } from "../../services/notify/notify-service.js"; + +export const notifyDisableCommand: CommandModule = { + data: new SlashCommandBuilder() + .setName("notify_disable") + .setDescription("μ•Œλ¦Ό κ·œμΉ™ λΉ„ν™œμ„±ν™”") + .addStringOption((opt) => opt.setName("rule_id").setDescription("κ·œμΉ™ ID").setRequired(true)), + async execute(interaction) { + if (!interaction.guildId) return; + const id = interaction.options.getString("rule_id", true); + await notifyService.disableRule(id, interaction.guildId); + await interaction.reply(`κ·œμΉ™ λΉ„ν™œμ„±ν™” μ™„λ£Œ: ${id}`); + }, +}; diff --git a/src/commands/notify/list.ts b/src/commands/notify/list.ts new file mode 100644 index 0000000..35c5b2f --- /dev/null +++ b/src/commands/notify/list.ts @@ -0,0 +1,19 @@ +import { SlashCommandBuilder } from "discord.js"; +import type { CommandModule } from "../../discord/types.js"; +import { notifyService } from "../../services/notify/notify-service.js"; + +export const notifyListCommand: CommandModule = { + data: new SlashCommandBuilder().setName("notify_list").setDescription("ν™œμ„± μ•Œλ¦Ό κ·œμΉ™ λͺ©λ‘"), + async execute(interaction) { + if (!interaction.guildId) return; + const rules = await notifyService.listRules(interaction.guildId); + if (rules.length === 0) { + await interaction.reply("ν™œμ„± μ•Œλ¦Ό κ·œμΉ™μ΄ μ—†μŠ΅λ‹ˆλ‹€."); + return; + } + const msg = rules + .map((r) => `- ${r.id} | <#${r.channelId}> | \`${r.cronExpr}\` | ${r.message.slice(0, 40)}`) + .join("\n"); + await interaction.reply(msg); + }, +}; diff --git a/src/commands/notify/schedule.ts b/src/commands/notify/schedule.ts new file mode 100644 index 0000000..8ed3fa3 --- /dev/null +++ b/src/commands/notify/schedule.ts @@ -0,0 +1,25 @@ +import { SlashCommandBuilder } from "discord.js"; +import type { CommandModule } from "../../discord/types.js"; +import { notifyService } from "../../services/notify/notify-service.js"; + +export const scheduleCommand: CommandModule = { + data: new SlashCommandBuilder() + .setName("notify_schedule") + .setDescription("μ˜ˆμ•½ μ•Œλ¦Ό κ·œμΉ™μ„ λ§Œλ“­λ‹ˆλ‹€") + .addChannelOption((opt) => opt.setName("channel").setDescription("μ•Œλ¦Ό 채널").setRequired(true)) + .addStringOption((opt) => opt.setName("cron").setDescription("크둠 식 (예: 0 9 * * *)").setRequired(true)) + .addStringOption((opt) => opt.setName("message").setDescription("μ•Œλ¦Ό λ©”μ‹œμ§€").setRequired(true)), + async execute(interaction) { + if (!interaction.guildId) return; + const channel = interaction.options.getChannel("channel", true); + const cronExpr = interaction.options.getString("cron", true); + const message = interaction.options.getString("message", true); + const rule = await notifyService.createRule({ + guildId: interaction.guildId, + channelId: channel.id, + cronExpr, + message, + }); + await interaction.reply(`μ•Œλ¦Ό κ·œμΉ™ 생성됨: ${rule.id}`); + }, +}; diff --git a/src/core/env.ts b/src/core/env.ts new file mode 100644 index 0000000..e3511cb --- /dev/null +++ b/src/core/env.ts @@ -0,0 +1,35 @@ +import "dotenv/config"; +import { z } from "zod"; + +const boolFromEnv = z.preprocess((value) => { + if (typeof value === "boolean") return value; + const text = String(value ?? "").trim().toLowerCase(); + return text === "1" || text === "true" || text === "yes" || text === "on"; +}, z.boolean()); + +const EnvSchema = z.object({ + DISCORD_TOKEN: z.string().min(1), + DISCORD_CLIENT_ID: z.string().min(1), + DISCORD_GUILD_ID: z.string().min(1).optional(), + DATABASE_URL: z.string().min(1), + REDIS_URL: z.string().min(1).optional(), + LAVALINK_NODE_NAME: z.string().default("local"), + LAVALINK_HOST: z.string().default("127.0.0.1"), + LAVALINK_PORT: z.coerce.number().default(2333), + LAVALINK_PASSWORD: z.string().default("youshallnotpass"), + LAVALINK_SECURE: boolFromEnv.default(false), + GEMINI_API_KEY: z.string().optional(), + GEMINI_FLASH_MODEL: z.string().default("gemini-2.5-flash"), + GEMINI_PRO_MODEL: z.string().default("gemini-2.5-pro"), + DEEPL_API_KEY: z.string().optional(), + DEEPL_CLI_BIN: z.string().default("deepl"), + DEEPL_CLI_TIMEOUT_MS: z.coerce.number().default(45000), + GDS_DVIEWER_BASE_URL: z.string().optional(), + GDS_DVIEWER_API_KEY: z.string().optional(), + GDS_DVIEWER_SOURCE_ID: z.string().default("0"), + EXTERNAL_VIDEO_PLAYER_URL: z.string().optional(), + IINA_LINK_ICON: z.string().default("🎬"), + LOG_LEVEL: z.string().default("info"), +}); + +export const env = EnvSchema.parse(process.env); diff --git a/src/core/logger.ts b/src/core/logger.ts new file mode 100644 index 0000000..20467f7 --- /dev/null +++ b/src/core/logger.ts @@ -0,0 +1,13 @@ +import pino from "pino"; +import { env } from "./env.js"; + +export const logger = pino({ + level: env.LOG_LEVEL, + transport: + process.env.NODE_ENV === "production" + ? undefined + : { + target: "pino-pretty", + options: { colorize: true, translateTime: "SYS:standard" }, + }, +}); diff --git a/src/db/prisma.ts b/src/db/prisma.ts new file mode 100644 index 0000000..901f3a0 --- /dev/null +++ b/src/db/prisma.ts @@ -0,0 +1,3 @@ +import { PrismaClient } from "@prisma/client"; + +export const prisma = new PrismaClient(); diff --git a/src/discord/client.ts b/src/discord/client.ts new file mode 100644 index 0000000..e40cc3e --- /dev/null +++ b/src/discord/client.ts @@ -0,0 +1,440 @@ +import { + ChannelType, + Client, + EmbedBuilder, + Events, + GatewayIntentBits, + type TextChannel, +} from "discord.js"; +import { env } from "../core/env.js"; +import { logger } from "../core/logger.js"; +import { commandMap } from "../commands/index.js"; +import { startNotifyWorker } from "../queue/notify-queue.js"; +import { musicPlayer } from "../services/music/music-player.js"; +import { newsService } from "../services/news/news-service.js"; +import { + summarizeService, + type SummarizeEngine, + type SummarizeMode, +} from "../services/summarize/summarize-service.js"; +import { translateService } from "../services/translate/translate-service.js"; +import { gdsAnimeService } from "../services/gds/gds-anime-service.js"; +import { gdsMovieService } from "../services/gds/gds-movie-service.js"; + +export function createBotClient() { + const PREFIX = "!"; + const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.GuildVoiceStates, + GatewayIntentBits.MessageContent, + ], + }); + musicPlayer.init(client); + + client.once(Events.ClientReady, () => { + logger.info({ user: client.user?.tag }, "Bot ready"); + }); + + client.on(Events.InteractionCreate, async (interaction) => { + if (!interaction.isChatInputCommand()) return; + + const cmd = commandMap.get(interaction.commandName); + if (!cmd) { + await interaction.reply({ content: "μ•Œ 수 μ—†λŠ” λͺ…λ Ήμž…λ‹ˆλ‹€.", ephemeral: true }); + return; + } + + try { + await cmd.execute(interaction); + } catch (error) { + logger.error({ error, command: interaction.commandName }, "Command execution failed"); + const message = "λͺ…λ Ή μ‹€ν–‰ 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€."; + if (interaction.replied || interaction.deferred) { + await interaction.followUp({ content: message, ephemeral: true }); + } else { + await interaction.reply({ content: message, ephemeral: true }); + } + } + }); + + client.on(Events.MessageCreate, async (message) => { + if (message.author.bot) return; + if (!message.guildId) return; + const content = String(message.content || "").trim(); + if (!content.startsWith(PREFIX)) return; + + const withoutPrefix = content.slice(PREFIX.length).trim(); + if (!withoutPrefix) return; + const [rawCommand, ...rest] = withoutPrefix.split(/\s+/); + const command = rawCommand.toLowerCase(); + const chunkText = (text: string, maxLen = 1800): string[] => { + const chunks: string[] = []; + let remaining = String(text || "").trim(); + while (remaining.length > maxLen) { + const cut = remaining.lastIndexOf("\n", maxLen); + const idx = cut > 200 ? cut : maxLen; + chunks.push(remaining.slice(0, idx).trim()); + remaining = remaining.slice(idx).trim(); + } + if (remaining) chunks.push(remaining); + return chunks; + }; + + try { + if (command === "play") { + const query = rest.join(" ").trim(); + if (!query) { + await message.reply("μ‚¬μš©λ²•: `!play <검색어 λ˜λŠ” URL>`"); + return; + } + const result = await musicPlayer.enqueueFromMessage(message, query); + await message.reply(result); + return; + } + + if (command === "queue") { + const queue = musicPlayer.list(message.guildId); + if (queue.length === 0) { + await message.reply("λŒ€κΈ°μ—΄μ΄ λΉ„μ–΄ μžˆμŠ΅λ‹ˆλ‹€."); + return; + } + await message.reply(`ν˜„μž¬ λŒ€κΈ°μ—΄:\n${queue.slice(0, 10).join("\n")}`); + return; + } + + if (command === "skip") { + const result = await musicPlayer.skip(message.guildId); + await message.reply(result); + return; + } + + if (command === "stop") { + const result = await musicPlayer.stop(message.guildId); + await message.reply(result); + return; + } + + if (command === "λ‰΄μŠ€" || command === "news") { + const query = rest.join(" ").trim(); + const items = await newsService.fetchGoogleNews(query, 10); + const title = query ? `ꡬ글 λ‰΄μŠ€: ${query}` : "ꡬ글 λ‰΄μŠ€: μ΅œμ‹ "; + const chunks = newsService.toDiscordMessageChunks(title, items, 1900); + const first = await message.reply({ + content: chunks[0], + allowedMentions: { parse: [] }, + }); + for (let i = 1; i < chunks.length; i += 1) { + await first.channel.send({ + content: chunks[i], + allowedMentions: { parse: [] }, + }); + } + return; + } + + if (command === "μš”μ•½" || command === "summarize") { + if (rest.length === 0) { + await message.reply("μ‚¬μš©λ²•: `!μš”μ•½ [auto|fast|quality] [ai|basic|both]`"); + return; + } + const tokens = [...rest]; + let engine: SummarizeEngine | "both" = "basic"; + const lastToken = String(tokens[tokens.length - 1] || "").toLowerCase(); + if (lastToken === "ai" || lastToken === "basic" || lastToken === "both" || lastToken === "비ꡐ") { + engine = lastToken === "비ꡐ" ? "both" : (lastToken as SummarizeEngine | "both"); + tokens.pop(); + } + + const maybeMode = String(tokens[tokens.length - 1] || "").toLowerCase(); + const mode: SummarizeMode = + maybeMode === "fast" || maybeMode === "quality" || maybeMode === "auto" + ? (maybeMode as SummarizeMode) + : "auto"; + const urlTokens = + maybeMode === "auto" || maybeMode === "fast" || maybeMode === "quality" + ? tokens.slice(0, -1) + : tokens; + const url = urlTokens.join(" ").trim(); + if (!url) { + await message.reply("μ‚¬μš©λ²•: `!μš”μ•½ [auto|fast|quality] [ai|basic|both]`"); + return; + } + + const aiResult = + engine === "ai" || engine === "both" + ? await summarizeService.summarizeUrl(url, mode, "ai") + : null; + const basicResult = + engine === "basic" || engine === "both" + ? await summarizeService.summarizeUrl(url, mode, "basic") + : null; + + const ref = aiResult || basicResult; + const sections: string[] = []; + if (aiResult) { + sections.push(`**[AI μš”μ•½]** model=\`${aiResult.model}\` call#${aiResult.callNo}\n${aiResult.summary}`); + } + if (basicResult) { + sections.push( + `**[λΉ„AI μš”μ•½]** model=\`${basicResult.model}\` call#${basicResult.callNo}\n${basicResult.summary}`, + ); + } + const header = `**μš”μ•½ μ™„λ£Œ**\n제λͺ©: ${ref?.title || "N/A"}\n원문: <${ref?.sourceUrl || url}>`; + const chunks = chunkText(`${header}\n\n${sections.join("\n\n")}`, 1800); + const first = await message.reply(chunks[0]); + for (let i = 1; i < chunks.length; i += 1) { + await first.channel.send(chunks[i]); + } + return; + } + + if (command === "λ²ˆμ—­" || command === "translate") { + if (rest.length === 0) { + await message.reply( + `μ‚¬μš©λ²•: \`!λ²ˆμ—­ [source->target] <ν…μŠ€νŠΈ>\`\n예: \`!λ²ˆμ—­ ja->ko こんにけは\`\nκΈ°λ³Έ: auto->ko, μ΅œλŒ€ ${translateService.getMaxInputChars()}자`, + ); + return; + } + + let source = "auto"; + let target = "ko"; + let tokens = [...rest]; + const firstToken = String(tokens[0] || ""); + if (/^[a-zA-Z-]{2,12}->[a-zA-Z-]{2,12}$/.test(firstToken)) { + const [src, dst] = firstToken.split("->"); + source = src.toLowerCase(); + target = dst.toLowerCase(); + tokens = tokens.slice(1); + } + + const text = tokens.join(" ").trim(); + if (!text) { + await message.reply("λ²ˆμ—­ν•  ν…μŠ€νŠΈλ₯Ό μž…λ ₯ν•˜μ„Έμš”."); + return; + } + + const result = await translateService.translate(text, source, target); + const header = `**λ²ˆμ—­ μ™„λ£Œ** engine=\`${result.engine}\` chars=${result.inputChars} chunks=${result.chunkCount}`; + const chunks = chunkText(`${header}\n\n${result.translatedText}`, 1800); + const first = await message.reply(chunks[0]); + for (let i = 1; i < chunks.length; i += 1) { + await first.channel.send(chunks[i]); + } + return; + } + + if (command === "ani" || command === "μ˜ν™”" || command === "movie") { + const query = rest.join(" ").trim(); + if (!query) { + const usage = command === "ani" ? '`!ani "제λͺ©"`' : '`!μ˜ν™” "제λͺ©"`'; + await message.reply(`μ‚¬μš©λ²•: ${usage}`); + return; + } + const isMovie = command === "μ˜ν™”" || command === "movie"; + const list = isMovie + ? await gdsMovieService.searchByTitle(query, 5) + : await gdsAnimeService.searchByTitle(query, 5); + if (list.length === 0) { + await message.reply(`검색 κ²°κ³Ό μ—†μŒ: ${query}`); + return; + } + + const trim = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s); + const buildExternalVideoModalLink = (item: { + name?: string; + path?: string; + source_id?: string | number; + stream_url?: string; + meta_poster?: string; + poster?: string; + thumb?: string; + }): string => { + const toUrlSafeBase64 = (input: string): string => { + const b64 = Buffer.from(String(input || ""), "utf8").toString("base64"); + return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); + }; + try { + const stream = String(item.stream_url || "").trim(); + if (!stream) return ""; + const externalPlayer = String(env.EXTERNAL_VIDEO_PLAYER_URL || "").trim(); + if (!externalPlayer) return ""; + const p = new URL(externalPlayer); + const explorerBase = String(env.GDS_DVIEWER_BASE_URL || "").trim(); + let apikey = String(env.GDS_DVIEWER_API_KEY || "").trim(); + if (!apikey && item.stream_url) { + try { + const su = new URL(item.stream_url); + apikey = String(su.searchParams.get("apikey") || "").trim(); + } catch {} + } + const sid = String(item.source_id ?? env.GDS_DVIEWER_SOURCE_ID ?? "0").trim() || "0"; + const autoplayPath = String(item.path || "").trim(); + if (explorerBase && autoplayPath) { + p.searchParams.set("b", toUrlSafeBase64(autoplayPath)); + p.searchParams.set("s", sid); + if (apikey) p.searchParams.set("k", apikey); + } + return p.toString(); + } catch { + return ""; + } + }; + const resolvePosterUrl = (item: { + path?: string; + source_id?: string | number; + meta_poster?: string; + poster?: string; + thumb?: string; + stream_url?: string; + }): string => { + const isPlaceholderPoster = (url: string): boolean => { + const u = String(url || "").toLowerCase(); + if (!u) return true; + return ( + u.includes("no_poster") || + u.includes("no-image") || + u.includes("no_image") || + u.includes("placeholder") || + u.endsWith("/no_poster.png") || + u.endsWith("/no_poster.svg") + ); + }; + let poster = + String(item.meta_poster || "").trim() || + String(item.poster || "").trim() || + String(item.thumb || "").trim(); + if (poster && !isPlaceholderPoster(poster)) { + try { + if (poster.startsWith("//")) return `https:${poster}`; + if (poster.startsWith("/")) { + if (!env.GDS_DVIEWER_BASE_URL) return poster; + const base = new URL(env.GDS_DVIEWER_BASE_URL); + return `${base.origin}${poster}`; + } + return poster; + } catch { + return poster; + } + } + + // Fallback 1: path 기반 thumbnail endpoint μ‚¬μš© (κ°€μž₯ μ•ˆμ •μ ) + try { + const base = String(env.GDS_DVIEWER_BASE_URL || "").trim(); + if (base && item.path) { + const bu = new URL(base); + const pkg = (bu.pathname.split("/").filter(Boolean)[0] || "gds_dviewer").trim(); + const t = new URL(`${bu.origin}/${pkg}/normal/thumbnail`); + t.searchParams.set("path", String(item.path || "")); + const sid = String(item.source_id ?? env.GDS_DVIEWER_SOURCE_ID ?? "0").trim(); + if (sid) t.searchParams.set("source_id", sid); + let apikey = String(env.GDS_DVIEWER_API_KEY || "").trim(); + if (!apikey && item.stream_url) { + try { + const su = new URL(item.stream_url); + apikey = String(su.searchParams.get("apikey") || "").trim(); + } catch {} + } + if (apikey) t.searchParams.set("apikey", apikey); + t.searchParams.set("w", "400"); + return t.toString(); + } + } catch {} + + // Fallback: stream_url의 bpath둜 thumbnail URL 생성 + try { + if (!item.stream_url) return ""; + const u = new URL(item.stream_url); + const bpath = u.searchParams.get("bpath"); + if (!bpath) return ""; + const thumb = new URL(u.toString()); + thumb.pathname = thumb.pathname.replace(/\/stream$/, "/thumbnail"); + thumb.search = ""; + thumb.searchParams.set("bpath", bpath); + const sourceId = u.searchParams.get("source_id") || env.GDS_DVIEWER_SOURCE_ID; + if (sourceId) thumb.searchParams.set("source_id", sourceId); + const apikey = u.searchParams.get("apikey") || env.GDS_DVIEWER_API_KEY || ""; + if (apikey) thumb.searchParams.set("apikey", apikey); + return thumb.toString(); + } catch { + return ""; + } + }; + const getEpisode = (text: string): string => { + const src = String(text || ""); + const patterns = [ + /(?:^|[\s._-])(E\d{1,4})(?:$|[\s._-])/i, + /(EP\.?\s*\d{1,4})/i, + /(제\s*\d{1,4}\s*ν™”)/i, + /(\d{1,4}\s*ν™”)/i, + /(\bS\d+\s*E\d+\b)/i, + ]; + for (const p of patterns) { + const m = src.match(p); + if (m?.[1]) return m[1].replace(/\s+/g, " ").trim().toUpperCase(); + } + return "-"; + }; + const getCleanTitle = (name: string): string => { + let t = String(name || "").replace(/\.[a-z0-9]{2,5}$/i, ""); + t = t.replace(/(EP\.?\s*\d{1,4}|제\s*\d{1,4}\s*ν™”|\d{1,4}\s*ν™”|\bS\d+\s*E\d+\b)/gi, ""); + t = t.replace(/[\[\(\{].*?[\]\)\}]/g, " "); + t = t.replace(/\s+/g, " ").trim(); + return t || String(name || "제λͺ© μ—†μŒ"); + }; + const summaryEmbed = new EmbedBuilder() + .setColor(0x4f46e5) + .setTitle(isMovie ? "μ˜ν™” 검색 κ²°κ³Ό" : "ANI 검색 κ²°κ³Ό") + .setDescription(`query: \`${trim(query, 80)}\`\n총 ${list.length}건`) + .setFooter({ text: "gds_dviewer search" }) + .setTimestamp(new Date()); + + const itemEmbeds = list.slice(0, 3).map((item, idx) => { + const poster = resolvePosterUrl(item); + const ep = getEpisode(`${item.name} ${item.path}`); + const cleanTitle = getCleanTitle(item.name || ""); + const descLines = [`제λͺ©: **${trim(cleanTitle, 100)}**`, `회차: **${ep}**`]; + const modalLink = item.stream_url ? buildExternalVideoModalLink(item) : ""; + if (modalLink) { + descLines.push(`μž¬μƒ: [μ™ΈλΆ€ ν”Œλ ˆμ΄μ–΄ μ—΄κΈ°](${modalLink})`); + } else if (item.stream_url) { + descLines.push("μ™ΈλΆ€ ν”Œλ ˆμ΄μ–΄ 링크 μ„€μ • ν•„μš”"); + } else { + descLines.push("μž¬μƒ 링크 μ—†μŒ"); + } + const e = new EmbedBuilder() + .setColor(0x1d4ed8) + .setTitle(trim(`${idx + 1}`, 32)) + .setDescription(trim(descLines.join("\n"), 1200)); + if (poster) { + e.setThumbnail(poster); + } + (e as any).__modalLink = modalLink; + return e; + }); + + await message.reply({ embeds: [summaryEmbed, ...itemEmbeds] }); + return; + } + } catch (error) { + logger.error({ error, command, from: "prefix" }, "Prefix command execution failed"); + await message.reply( + `μ‹€ν–‰ μ‹€νŒ¨: ${error instanceof Error ? error.message : "μ•Œ 수 μ—†λŠ” 였λ₯˜"}`, + ); + } + }); + + startNotifyWorker(async (job) => { + const channel = await client.channels.fetch(job.channelId); + if (!channel || channel.type !== ChannelType.GuildText) return; + await (channel as TextChannel).send(job.message); + }); + + return { + client, + async start() { + await client.login(env.DISCORD_TOKEN); + }, + }; +} diff --git a/src/discord/register-commands.ts b/src/discord/register-commands.ts new file mode 100644 index 0000000..85e6199 --- /dev/null +++ b/src/discord/register-commands.ts @@ -0,0 +1,21 @@ +import { REST, Routes } from "discord.js"; +import { env } from "../core/env.js"; +import { logger } from "../core/logger.js"; +import { commands } from "../commands/index.js"; + +export async function registerCommands() { + const rest = new REST({ version: "10" }).setToken(env.DISCORD_TOKEN); + const body = commands.map((c) => c.data.toJSON()); + + if (env.DISCORD_GUILD_ID) { + await rest.put( + Routes.applicationGuildCommands(env.DISCORD_CLIENT_ID, env.DISCORD_GUILD_ID), + { body }, + ); + logger.info({ guildId: env.DISCORD_GUILD_ID, count: body.length }, "Guild commands registered"); + return; + } + + await rest.put(Routes.applicationCommands(env.DISCORD_CLIENT_ID), { body }); + logger.info({ count: body.length }, "Global commands registered"); +} diff --git a/src/discord/types.ts b/src/discord/types.ts new file mode 100644 index 0000000..53aa330 --- /dev/null +++ b/src/discord/types.ts @@ -0,0 +1,14 @@ +import type { + ChatInputCommandInteraction, + RESTPostAPIChatInputApplicationCommandsJSONBody, +} from "discord.js"; + +export type SlashLikeBuilder = { + name: string; + toJSON: () => RESTPostAPIChatInputApplicationCommandsJSONBody; +}; + +export interface CommandModule { + data: SlashLikeBuilder; + execute: (interaction: ChatInputCommandInteraction) => Promise; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..d867272 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,26 @@ +import { prisma } from "./db/prisma.js"; +import { logger } from "./core/logger.js"; +import { registerCommands } from "./discord/register-commands.js"; +import { createBotClient } from "./discord/client.js"; + +async function bootstrap() { + await prisma.$connect(); + await registerCommands(); + const bot = createBotClient(); + await bot.start(); +} + +bootstrap().catch(async (error) => { + logger.error({ error }, "Fatal bootstrap error"); + try { + await prisma.$disconnect(); + } catch { + // noop + } + process.exit(1); +}); + +process.on("SIGINT", async () => { + await prisma.$disconnect(); + process.exit(0); +}); diff --git a/src/queue/notify-queue.ts b/src/queue/notify-queue.ts new file mode 100644 index 0000000..b9973c5 --- /dev/null +++ b/src/queue/notify-queue.ts @@ -0,0 +1,39 @@ +import { Queue, Worker, type JobsOptions } from "bullmq"; +import { env } from "../core/env.js"; +import { logger } from "../core/logger.js"; + +export type NotifyJob = { + guildId: string; + channelId: string; + message: string; +}; + +const hasRedis = Boolean(env.REDIS_URL); +const connection = hasRedis ? { url: env.REDIS_URL! } : null; + +export const notifyQueue = hasRedis + ? new Queue("notify-queue", { connection: connection! }) + : null; + +export const addNotifyJob = async (payload: NotifyJob, opts?: JobsOptions) => { + if (!notifyQueue) return false; + await notifyQueue.add("notify", payload, opts); + return true; +}; + +export const startNotifyWorker = ( + handler: (job: NotifyJob) => Promise, +): Worker | null => { + if (!connection) { + logger.warn("REDIS_URL not set. BullMQ worker disabled."); + return null; + } + + return new Worker( + "notify-queue", + async (job) => { + await handler(job.data); + }, + { connection }, + ); +}; diff --git a/src/services/gds/gds-anime-service.ts b/src/services/gds/gds-anime-service.ts new file mode 100644 index 0000000..54dcddc --- /dev/null +++ b/src/services/gds/gds-anime-service.ts @@ -0,0 +1,156 @@ +import { env } from "../../core/env.js"; + +type GdsAniItem = { + name: string; + path: string; + stream_url?: string; + mtime?: string; + is_dir?: boolean; + meta_poster?: string; + poster?: string; + thumb?: string; +}; + +type GdsAniSearchResponse = { + ret?: string; + count?: number; + list?: GdsAniItem[]; +}; + +function normalizeBaseUrl(baseUrl: string): string { + return baseUrl.replace(/\/+$/, ""); +} + +function stripOuterQuotes(input: string): string { + const t = String(input || "").trim(); + if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) { + return t.slice(1, -1).trim(); + } + return t; +} + +function normalizeForMatch(input: string): string { + return String(input || "") + .toLowerCase() + .replace(/[\[\]\(\)\{\}\-_.:]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function scoreItem(query: string, item: GdsAniItem): number { + const q = normalizeForMatch(query); + const name = normalizeForMatch(item.name || ""); + const path = normalizeForMatch(item.path || ""); + const compactQ = q.replace(/\s+/g, ""); + const compactName = name.replace(/\s+/g, ""); + const compactPath = path.replace(/\s+/g, ""); + const tokens = q.split(" ").filter(Boolean); + + let score = 0; + if (compactQ && compactName.includes(compactQ)) score += 120; + if (compactQ && compactPath.includes(compactQ)) score += 60; + for (const t of tokens) { + if (name.includes(t)) score += 18; + if (path.includes(t)) score += 7; + } + if (!item.is_dir) score += 5; + return score; +} + +async function fetchSearch( + base: string, + apiKey: string, + query: string, + sourceId: string, + limit: number, + parentPath: string | null, + isDir: boolean, +): Promise { + const url = new URL(`${base}/search`); + url.searchParams.set("apikey", apiKey); + url.searchParams.set("query", query); + if (parentPath) { + url.searchParams.set("parent_path", parentPath); + } + url.searchParams.set("recursive", "true"); + url.searchParams.set("limit", String(limit)); + url.searchParams.set("offset", "0"); + url.searchParams.set("is_dir", isDir ? "true" : "false"); + // 제λͺ©μ΄ path에 μžˆμ„ 수 μžˆμ–΄ true둜 λ‘”λ‹€. + url.searchParams.set("search_in_path", "true"); + url.searchParams.set("sort_by", "date"); + url.searchParams.set("sort_order", "desc"); + url.searchParams.set("video_only", "true"); + url.searchParams.set("source_id", sourceId); + + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 12000); + try { + const response = await fetch(url.toString(), { signal: ctrl.signal }); + if (!response.ok) { + return []; + } + const payload = (await response.json()) as GdsAniSearchResponse; + if (payload?.ret !== "success") { + return []; + } + const list = Array.isArray(payload.list) ? payload.list : []; + return list.filter((item) => Boolean(item?.path)); + } finally { + clearTimeout(timer); + } +} + +export const gdsAnimeService = { + async searchByTitle(rawQuery: string, limit = 5): Promise { + const query = stripOuterQuotes(rawQuery); + if (!query) throw new Error("검색어가 λΉ„μ—ˆμŠ΅λ‹ˆλ‹€."); + if (!env.GDS_DVIEWER_BASE_URL || !env.GDS_DVIEWER_API_KEY) { + throw new Error("GDS_DVIEWER_BASE_URL / GDS_DVIEWER_API_KEY 섀정이 ν•„μš”ν•©λ‹ˆλ‹€."); + } + + const base = normalizeBaseUrl(env.GDS_DVIEWER_BASE_URL); + const apiKey = env.GDS_DVIEWER_API_KEY; + const max = Math.max(1, Math.min(limit, 10)); + const fetchLimit = Math.max(20, Math.min(100, max * 8)); + const parentCandidates: Array = [ + "VIDEO/μ• λ‹ˆλ©”μ΄μ…˜", + "VIDEO/일본 μ• λ‹ˆλ©”μ΄μ…˜", + "VIDEO/방솑쀑/라프텔 μ• λ‹ˆλ©”μ΄μ…˜", + "VIDEO/방솑쀑/OTT μ• λ‹ˆλ©”μ΄μ…˜", + "VIDEO", + null, + ]; + + const seen = new Set(); + const merged: GdsAniItem[] = []; + + for (const parentPath of parentCandidates) { + for (const isDir of [false, true]) { + const rows = await fetchSearch( + base, + apiKey, + query, + env.GDS_DVIEWER_SOURCE_ID, + fetchLimit, + parentPath, + isDir, + ); + for (const row of rows) { + if (!seen.has(row.path)) { + seen.add(row.path); + merged.push(row); + } + } + } + } + return merged + .map((item) => ({ item, score: scoreItem(query, item) })) + .sort((a, b) => b.score - a.score) + .filter((row) => row.score > 0) + .slice(0, max) + .map((row) => row.item); + }, +}; + +export type { GdsAniItem }; diff --git a/src/services/gds/gds-movie-service.ts b/src/services/gds/gds-movie-service.ts new file mode 100644 index 0000000..0554e65 --- /dev/null +++ b/src/services/gds/gds-movie-service.ts @@ -0,0 +1,149 @@ +import { env } from "../../core/env.js"; + +type GdsMovieItem = { + name: string; + path: string; + stream_url?: string; + mtime?: string; + is_dir?: boolean; + source_id?: string | number; + meta_poster?: string; + poster?: string; + thumb?: string; +}; + +type GdsMovieSearchResponse = { + ret?: string; + count?: number; + list?: GdsMovieItem[]; +}; + +function normalizeBaseUrl(baseUrl: string): string { + return baseUrl.replace(/\/+$/, ""); +} + +function stripOuterQuotes(input: string): string { + const t = String(input || "").trim(); + if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) { + return t.slice(1, -1).trim(); + } + return t; +} + +function normalizeForMatch(input: string): string { + return String(input || "") + .toLowerCase() + .replace(/[\[\]\(\)\{\}\-_.:]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function scoreItem(query: string, item: GdsMovieItem): number { + const q = normalizeForMatch(query); + const name = normalizeForMatch(item.name || ""); + const path = normalizeForMatch(item.path || ""); + const compactQ = q.replace(/\s+/g, ""); + const compactName = name.replace(/\s+/g, ""); + const compactPath = path.replace(/\s+/g, ""); + const tokens = q.split(" ").filter(Boolean); + + let score = 0; + if (compactQ && compactName.includes(compactQ)) score += 120; + if (compactQ && compactPath.includes(compactQ)) score += 60; + for (const t of tokens) { + if (name.includes(t)) score += 18; + if (path.includes(t)) score += 7; + } + if (!item.is_dir) score += 5; + return score; +} + +async function fetchSearch( + base: string, + apiKey: string, + query: string, + sourceId: string, + limit: number, + parentPath: string | null, + isDir: boolean, +): Promise { + const url = new URL(`${base}/search`); + url.searchParams.set("apikey", apiKey); + url.searchParams.set("query", query); + if (parentPath) url.searchParams.set("parent_path", parentPath); + url.searchParams.set("recursive", "true"); + url.searchParams.set("limit", String(limit)); + url.searchParams.set("offset", "0"); + url.searchParams.set("is_dir", isDir ? "true" : "false"); + url.searchParams.set("search_in_path", "true"); + url.searchParams.set("sort_by", "date"); + url.searchParams.set("sort_order", "desc"); + url.searchParams.set("video_only", "true"); + url.searchParams.set("source_id", sourceId); + + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 12000); + try { + const response = await fetch(url.toString(), { signal: ctrl.signal }); + if (!response.ok) return []; + const payload = (await response.json()) as GdsMovieSearchResponse; + if (payload?.ret !== "success") return []; + const list = Array.isArray(payload.list) ? payload.list : []; + return list.filter((item) => Boolean(item?.path)); + } finally { + clearTimeout(timer); + } +} + +export const gdsMovieService = { + async searchByTitle(rawQuery: string, limit = 5): Promise { + const query = stripOuterQuotes(rawQuery); + if (!query) throw new Error("검색어가 λΉ„μ—ˆμŠ΅λ‹ˆλ‹€."); + if (!env.GDS_DVIEWER_BASE_URL || !env.GDS_DVIEWER_API_KEY) { + throw new Error("GDS_DVIEWER_BASE_URL / GDS_DVIEWER_API_KEY 섀정이 ν•„μš”ν•©λ‹ˆλ‹€."); + } + + const base = normalizeBaseUrl(env.GDS_DVIEWER_BASE_URL); + const apiKey = env.GDS_DVIEWER_API_KEY; + const max = Math.max(1, Math.min(limit, 10)); + const fetchLimit = Math.max(20, Math.min(100, max * 8)); + const parentCandidates: Array = [ + "VIDEO/μ˜ν™”", + "VIDEO/μ˜ν™”/μ΅œμ‹ ", + "VIDEO/μ˜ν™”/제λͺ©", + "VIDEO/μ˜ν™”/UHD", + "VIDEO", + null, + ]; + + const seen = new Set(); + const merged: GdsMovieItem[] = []; + for (const parentPath of parentCandidates) { + for (const isDir of [false, true]) { + const rows = await fetchSearch( + base, + apiKey, + query, + env.GDS_DVIEWER_SOURCE_ID, + fetchLimit, + parentPath, + isDir, + ); + for (const row of rows) { + if (!seen.has(row.path)) { + seen.add(row.path); + merged.push(row); + } + } + } + } + return merged + .map((item) => ({ item, score: scoreItem(query, item) })) + .sort((a, b) => b.score - a.score) + .filter((row) => row.score > 0) + .slice(0, max) + .map((row) => row.item); + }, +}; + +export type { GdsMovieItem }; diff --git a/src/services/moderation/moderation-service.ts b/src/services/moderation/moderation-service.ts new file mode 100644 index 0000000..dc420e7 --- /dev/null +++ b/src/services/moderation/moderation-service.ts @@ -0,0 +1,20 @@ +import { prisma } from "../../db/prisma.js"; + +export const moderationService = { + async warnUser(input: { + guildId: string; + userId: string; + moderatorId: string; + reason: string; + }) { + return prisma.warning.create({ data: input }); + }, + + async getWarnings(guildId: string, userId: string) { + return prisma.warning.findMany({ + where: { guildId, userId }, + orderBy: { createdAt: "desc" }, + take: 10, + }); + }, +}; diff --git a/src/services/music/music-player.ts b/src/services/music/music-player.ts new file mode 100644 index 0000000..1ff76c0 --- /dev/null +++ b/src/services/music/music-player.ts @@ -0,0 +1,222 @@ +import type { Client, CommandInteraction, GuildMember, Message } from "discord.js"; +import { Connectors, LoadType, Shoukaku, type Player, type Track } from "shoukaku"; +import { env } from "../../core/env.js"; +import { logger } from "../../core/logger.js"; + +type QueueItem = { + track: Track; + requestedBy: string; +}; + +type GuildState = { + queue: QueueItem[]; + textChannelId: string | null; + listenersBound: boolean; +}; + +class MusicPlayerService { + private shoukaku: Shoukaku | null = null; + private readonly guildState = new Map(); + + init(client: Client): void { + if (this.shoukaku) return; + + this.shoukaku = new Shoukaku( + new Connectors.DiscordJS(client), + [ + { + name: env.LAVALINK_NODE_NAME, + url: `${env.LAVALINK_HOST}:${env.LAVALINK_PORT}`, + auth: env.LAVALINK_PASSWORD, + secure: env.LAVALINK_SECURE, + }, + ], + { + resume: true, + resumeTimeout: 60, + reconnectTries: 999, + reconnectInterval: 5, + moveOnDisconnect: true, + }, + ); + + this.shoukaku.on("ready", (name, reconnected) => { + logger.info({ name, reconnected }, "Lavalink node ready"); + }); + this.shoukaku.on("error", (name, error) => { + logger.error({ name, error }, "Lavalink node error"); + }); + this.shoukaku.on("disconnect", (name, count) => { + logger.warn({ name, count }, "Lavalink node disconnected"); + }); + } + + private getState(guildId: string): GuildState { + const existing = this.guildState.get(guildId); + if (existing) return existing; + const created: GuildState = { queue: [], textChannelId: null, listenersBound: false }; + this.guildState.set(guildId, created); + return created; + } + + private getNode() { + const node = this.shoukaku?.getIdealNode(); + if (!node) throw new Error("Lavalink node is not ready. Check lavalink server status."); + return node; + } + + private bindPlayerEvents(player: Player, guildId: string): void { + const state = this.getState(guildId); + if (state.listenersBound) return; + + player.on("end", async (event) => { + if (["finished", "loadFailed", "stopped"].includes(event.reason)) { + await this.playNext(guildId).catch((error) => + logger.error({ guildId, error }, "Failed to play next track after end"), + ); + } + }); + + player.on("exception", (event) => { + logger.error({ guildId, event }, "Track exception"); + }); + + state.listenersBound = true; + } + + private async ensurePlayer(member: GuildMember): Promise { + if (!this.shoukaku) throw new Error("Music service is not initialized."); + if (!member.voice.channelId) { + throw new Error("λ¨Όμ € μŒμ„± 채널에 μž…μž₯ν•΄μ£Όμ„Έμš”."); + } + + const guildId = member.guild.id; + // Always re-issue voice join to refresh session payload after reconnects. + const player = await this.shoukaku.joinVoiceChannel({ + guildId, + channelId: member.voice.channelId, + shardId: member.guild.shardId ?? 0, + deaf: true, + mute: false, + }); + + await player.setPaused(false); + await player.setGlobalVolume(100); + this.bindPlayerEvents(player, guildId); + return player; + } + + private async searchTrack(query: string): Promise { + const node = this.getNode(); + const identifier = /^https?:\/\//i.test(query) ? query : `ytsearch:${query}`; + const result = await node.rest.resolve(identifier); + if (!result) return []; + + if (result.loadType === LoadType.TRACK) return [result.data]; + if (result.loadType === LoadType.SEARCH) return result.data; + if (result.loadType === LoadType.PLAYLIST) return result.data.tracks; + return []; + } + + private async playNext(guildId: string): Promise { + if (!this.shoukaku) return; + const player = this.shoukaku.players.get(guildId); + if (!player) return; + + const state = this.getState(guildId); + const next = state.queue.shift(); + if (!next) return; + + await player.playTrack({ track: { encoded: next.track.encoded } }); + } + + private async enqueueCore(input: { + guildId: string; + channelId: string; + userId: string; + member: GuildMember; + query: string; + }): Promise { + const player = await this.ensurePlayer(input.member); + const tracks = await this.searchTrack(input.query); + if (tracks.length === 0) { + throw new Error("μž¬μƒ κ°€λŠ₯ν•œ νŠΈλž™μ„ μ°Ύμ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€."); + } + + const state = this.getState(input.guildId); + state.textChannelId = input.channelId; + + const queueItems: QueueItem[] = tracks.map((track) => ({ track, requestedBy: input.userId })); + + if (!player.track) { + const [first, ...rest] = queueItems; + await player.playTrack({ track: { encoded: first.track.encoded } }); + state.queue.push(...rest); + + if (tracks.length > 1) { + return `μž¬μƒ μ‹œμž‘: **${first.track.info.title}** (μΆ”κ°€ ${tracks.length - 1}곑)`; + } + return `μž¬μƒ μ‹œμž‘: **${first.track.info.title}**`; + } + + state.queue.push(...queueItems); + return `큐에 좔가됨: **${tracks[0].info.title}** (λŒ€κΈ°μ—΄ ${state.queue.length}개)`; + } + + async enqueueFromInteraction(interaction: CommandInteraction, query: string): Promise { + if (!interaction.guildId || !interaction.guild || !interaction.channelId) { + throw new Error("κΈΈλ“œ μ±„λ„μ—μ„œλ§Œ μ‚¬μš©ν•  수 μžˆμŠ΅λ‹ˆλ‹€."); + } + + const member = interaction.member as GuildMember; + return this.enqueueCore({ + guildId: interaction.guildId, + channelId: interaction.channelId, + userId: interaction.user.id, + member, + query, + }); + } + + async enqueueFromMessage(message: Message, query: string): Promise { + if (!message.guildId || !message.guild || !message.channelId) { + throw new Error("κΈΈλ“œ μ±„λ„μ—μ„œλ§Œ μ‚¬μš©ν•  수 μžˆμŠ΅λ‹ˆλ‹€."); + } + const member = message.member ?? (await message.guild.members.fetch(message.author.id)); + return this.enqueueCore({ + guildId: message.guildId, + channelId: message.channelId, + userId: message.author.id, + member, + query, + }); + } + + async skip(guildId: string): Promise { + if (!this.shoukaku) throw new Error("Music service is not initialized."); + const player = this.shoukaku.players.get(guildId); + if (!player || !player.track) throw new Error("μŠ€ν‚΅ν•  νŠΈλž™μ΄ μ—†μŠ΅λ‹ˆλ‹€."); + + await player.stopTrack(); + return "ν˜„μž¬ νŠΈλž™μ„ μŠ€ν‚΅ν–ˆμŠ΅λ‹ˆλ‹€."; + } + + async stop(guildId: string): Promise { + if (!this.shoukaku) throw new Error("Music service is not initialized."); + const player = this.shoukaku.players.get(guildId); + if (!player) throw new Error("ν™œμ„± ν”Œλ ˆμ΄μ–΄κ°€ μ—†μŠ΅λ‹ˆλ‹€."); + + const state = this.getState(guildId); + state.queue = []; + await player.stopTrack(); + await this.shoukaku.leaveVoiceChannel(guildId); + return "μž¬μƒμ„ μ€‘μ§€ν•˜κ³  μŒμ„± μ±„λ„μ—μ„œ λ‚˜κ°”μŠ΅λ‹ˆλ‹€."; + } + + list(guildId: string): string[] { + const state = this.getState(guildId); + return state.queue.map((q, i) => `${i + 1}. ${q.track.info.title}`); + } +} + +export const musicPlayer = new MusicPlayerService(); diff --git a/src/services/news/news-service.ts b/src/services/news/news-service.ts new file mode 100644 index 0000000..85a75bf --- /dev/null +++ b/src/services/news/news-service.ts @@ -0,0 +1,93 @@ +import { XMLParser } from "fast-xml-parser"; + +type NewsItem = { + title: string; + link: string; + pubDate?: string; +}; + +const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: "", + trimValues: true, +}); + +function toArray(value: T | T[] | undefined): T[] { + if (!value) return []; + return Array.isArray(value) ? value : [value]; +} + +function decodeGoogleNewsLink(link: string): string { + return String(link || "").trim(); +} + +function escapeTitleForMarkdown(text: string): string { + return String(text || "") + .replace(/\\/g, "\\\\") + .replace(/\[/g, "\\[") + .replace(/\]/g, "\\]") + .replace(/\(/g, "\\(") + .replace(/\)/g, "\\)") + .trim(); +} + +export const newsService = { + async fetchGoogleNews(keyword?: string, limit = 10): Promise { + const q = String(keyword || "").trim(); + const endpoint = q + ? `https://news.google.com/rss/search?q=${encodeURIComponent(q)}&hl=ko&gl=KR&ceid=KR:ko` + : "https://news.google.com/rss?hl=ko&gl=KR&ceid=KR:ko"; + + const response = await fetch(endpoint, { + headers: { + "User-Agent": "discord-multibot-news/0.1", + Accept: "application/rss+xml, application/xml, text/xml;q=0.9, */*;q=0.8", + }, + }); + + if (!response.ok) { + throw new Error(`λ‰΄μŠ€ μš”μ²­ μ‹€νŒ¨: HTTP ${response.status}`); + } + + const xml = await response.text(); + const parsed = parser.parse(xml); + const channel = parsed?.rss?.channel; + const items = toArray(channel?.item); + + return items + .map((item: any) => ({ + title: String(item?.title || "").trim(), + link: decodeGoogleNewsLink(String(item?.link || "").trim()), + pubDate: String(item?.pubDate || "").trim(), + })) + .filter((n) => n.title && n.link) + .slice(0, Math.max(1, Math.min(limit, 10))); + }, + + toDiscordMessageChunks(title: string, items: NewsItem[], maxLen = 1900): string[] { + const safeMax = Math.max(800, Math.min(maxLen, 1990)); + if (!items.length) return [`**${title}**\nν‘œμ‹œν•  λ‰΄μŠ€κ°€ μ—†μŠ΅λ‹ˆλ‹€.`]; + + const lines = items.map((n, i) => { + const compactTitle = + n.title.length > 120 ? `${n.title.slice(0, 117).trim()}...` : n.title; + // Wrap URL with <> to keep clickable link while preventing auto-embed cards. + return `${i + 1}. [${escapeTitleForMarkdown(compactTitle)}](<${n.link}>)`; + }); + + const chunks: string[] = []; + let current = `**${title}**\n`; + + for (const line of lines) { + const next = `${current}${line}\n`; + if (next.length > safeMax) { + chunks.push(current.trimEnd()); + current = `${line}\n`; + } else { + current = next; + } + } + if (current.trim()) chunks.push(current.trimEnd()); + return chunks; + }, +}; diff --git a/src/services/notify/notify-service.ts b/src/services/notify/notify-service.ts new file mode 100644 index 0000000..3013fd6 --- /dev/null +++ b/src/services/notify/notify-service.ts @@ -0,0 +1,27 @@ +import { prisma } from "../../db/prisma.js"; + +export const notifyService = { + async createRule(input: { + guildId: string; + channelId: string; + cronExpr: string; + message: string; + }) { + return prisma.notificationRule.create({ data: input }); + }, + + async listRules(guildId: string) { + return prisma.notificationRule.findMany({ + where: { guildId, enabled: true }, + orderBy: { createdAt: "desc" }, + take: 20, + }); + }, + + async disableRule(id: string, guildId: string) { + return prisma.notificationRule.update({ + where: { id }, + data: { enabled: false }, + }); + }, +}; diff --git a/src/services/summarize/summarize-service.ts b/src/services/summarize/summarize-service.ts new file mode 100644 index 0000000..9964a5d --- /dev/null +++ b/src/services/summarize/summarize-service.ts @@ -0,0 +1,278 @@ +import { load } from "cheerio"; +import { env } from "../../core/env.js"; +import { logger } from "../../core/logger.js"; + +type SummarizeMode = "auto" | "fast" | "quality"; +type SummarizeEngine = "ai" | "basic"; + +type SummarizeResult = { + summary: string; + model: string; + title: string; + sourceUrl: string; + inputChars: number; + callNo: number; +}; + +const stats = { + totalCalls: 0, + modelCalls: new Map(), + basicCalls: 0, +}; + +function chooseModel(mode: SummarizeMode, inputChars: number): string { + if (mode === "fast") return env.GEMINI_FLASH_MODEL; + if (mode === "quality") return env.GEMINI_PRO_MODEL; + return inputChars > 8000 ? env.GEMINI_PRO_MODEL : env.GEMINI_FLASH_MODEL; +} + +function cleanWhitespace(text: string): string { + return String(text || "").replace(/\s+/g, " ").trim(); +} + +function extractMainText(html: string): { title: string; text: string } { + const $ = load(html); + $("script,style,noscript,iframe,svg,header,footer,nav,aside,form").remove(); + + const title = cleanWhitespace($("title").first().text()) || "Untitled"; + + const collect = (selector: string) => + $(selector) + .map((_, el) => cleanWhitespace($(el).text())) + .get() + .filter((t) => t.length >= 15); + + const articleLines = [...collect("article p"), ...collect("article li")]; + const mainLines = [...collect("main p"), ...collect("main li")]; + const bodyLines = [...collect("p"), ...collect("li")]; + + const lines = articleLines.length + ? articleLines + : mainLines.length + ? mainLines + : bodyLines; + + const uniqLines = Array.from(new Set(lines)); + let text = uniqLines.join("\n"); + if (!text) { + text = cleanWhitespace($("body").text()); + } + text = text.slice(0, 20000); + + return { title, text }; +} + +function buildBasicSummary(text: string): string { + const lines = text + .split("\n") + .map((line) => cleanWhitespace(line)) + .filter((line) => line.length >= 20); + if (lines.length === 0) { + return "λ³Έλ¬Έμ—μ„œ μœ μ˜λ―Έν•œ λ¬Έμž₯을 μ°Ύμ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€."; + } + + const uniqLines = Array.from(new Set(lines)).slice(0, 200); + const stopwords = new Set([ + "그리고", + "κ·ΈλŸ¬λ‚˜", + "λ˜ν•œ", + "μ—μ„œ", + "으둜", + "이닀", + "μžˆλ‹€", + "ν•©λ‹ˆλ‹€", + "the", + "and", + "for", + "with", + "that", + "this", + "from", + ]); + + const tokenize = (input: string): string[] => + input + .toLowerCase() + .replace(/[^\p{L}\p{N}\s]/gu, " ") + .split(/\s+/) + .filter((tok) => tok.length >= 2 && !stopwords.has(tok)); + + const freq = new Map(); + for (const line of uniqLines) { + for (const token of tokenize(line)) { + freq.set(token, (freq.get(token) || 0) + 1); + } + } + + const scored = uniqLines.map((line, idx) => { + const tokens = tokenize(line); + const tokenScore = + tokens.length === 0 + ? 0 + : tokens.reduce((sum, tok) => sum + (freq.get(tok) || 0), 0) / tokens.length; + const lengthBonus = Math.min(line.length / 180, 1); + return { line, idx, score: tokenScore + lengthBonus }; + }); + + const top = scored + .sort((a, b) => b.score - a.score) + .slice(0, 5) + .sort((a, b) => a.idx - b.idx) + .map((item) => item.line); + + const bullets = top.map((line) => `- ${line}`).join("\n"); + const oneLine = top[0] || uniqLines[0]; + return `${bullets}\nν•œμ€„μš”μ•½: ${oneLine}`; +} + +async function fetchHtml(url: string): Promise { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 12000); + try { + const response = await fetch(url, { + signal: ctrl.signal, + headers: { + "User-Agent": "discord-multibot-summarizer/0.1", + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + }, + }); + if (!response.ok) { + throw new Error(`λ³Έλ¬Έ μš”μ²­ μ‹€νŒ¨: HTTP ${response.status}`); + } + return await response.text(); + } finally { + clearTimeout(timer); + } +} + +async function callGemini(model: string, prompt: string, maxOutputTokens = 1200): Promise { + if (!env.GEMINI_API_KEY) { + throw new Error("GEMINI_API_KEYκ°€ μ„€μ •λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€."); + } + + const endpoint = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(env.GEMINI_API_KEY)}`; + const response = await fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + contents: [{ parts: [{ text: prompt }] }], + generationConfig: { + temperature: 0.2, + maxOutputTokens, + }, + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Gemini μš”μ²­ μ‹€νŒ¨: HTTP ${response.status} ${text.slice(0, 180)}`); + } + + const data = (await response.json()) as any; + const text = data?.candidates?.[0]?.content?.parts + ?.map((p: any) => String(p?.text || "")) + .join("\n") + .trim(); + + if (!text) { + throw new Error("Gemini μ‘λ‹΅μ—μ„œ μš”μ•½ ν…μŠ€νŠΈλ₯Ό μ°Ύμ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€."); + } + return text; +} + +function looksTruncated(text: string): boolean { + const t = String(text || "").trim(); + if (!t) return true; + if (!t.includes("ν•œμ€„μš”μ•½:")) return true; + const ending = t.slice(-1); + return ![".", "!", "?", "”", "\"", "λ‹€"].includes(ending); +} + +export const summarizeService = { + async summarizeUrl( + url: string, + mode: SummarizeMode = "auto", + engine: SummarizeEngine = "ai", + ): Promise { + const cleanUrl = String(url || "").trim(); + if (!/^https?:\/\//i.test(cleanUrl)) { + throw new Error("http/https URL만 μš”μ•½ν•  수 μžˆμŠ΅λ‹ˆλ‹€."); + } + + const html = await fetchHtml(cleanUrl); + const { title, text } = extractMainText(html); + if (!text || text.length < 80) { + throw new Error("μš”μ•½ν•  본문을 μΆ©λΆ„νžˆ μΆ”μΆœν•˜μ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€."); + } + + if (engine === "basic") { + const summary = buildBasicSummary(text); + stats.basicCalls += 1; + logger.info( + { + type: "summarize-basic", + callNo: stats.basicCalls, + model: "rule-based-v1", + inputChars: text.length, + url: cleanUrl, + }, + "Summarize BASIC call", + ); + return { + summary, + model: "rule-based-v1", + title, + sourceUrl: cleanUrl, + inputChars: text.length, + callNo: stats.basicCalls, + }; + } + + const model = chooseModel(mode, text.length); + const prompt = [ + "λ‹€μŒ μ›ΉνŽ˜μ΄μ§€ 본문을 ν•œκ΅­μ–΄λ‘œ μš”μ•½ν•˜λΌ.", + "μš”κ΅¬μ‚¬ν•­:", + "1) 핡심 μš”μ  5개 이내 뢈릿", + "2) 사싀 μœ„μ£Ό, κ³Όμž₯ κΈˆμ§€", + "3) λ§ˆμ§€λ§‰ 쀄에 'ν•œμ€„μš”μ•½:' μΆ”κ°€", + "4) 전체 1200자 이내", + "", + `[제λͺ©] ${title}`, + "[λ³Έλ¬Έ]", + text, + ].join("\n"); + + let summary = await callGemini(model, prompt, 1400); + if (looksTruncated(summary)) { + const retryPrompt = `${prompt}\n\n주의: 응닡이 쀑간에 λŠκΈ°μ§€ μ•Šκ²Œ μ™„κ²°λœ λ¬Έμž₯으둜 끝내고 λ°˜λ“œμ‹œ 'ν•œμ€„μš”μ•½:' 쀄을 ν¬ν•¨ν•˜μ„Έμš”.`; + summary = await callGemini(model, retryPrompt, 1800); + } + + stats.totalCalls += 1; + const prev = stats.modelCalls.get(model) || 0; + stats.modelCalls.set(model, prev + 1); + + logger.info( + { + type: "summarize", + callNo: stats.totalCalls, + model, + modelCalls: stats.modelCalls.get(model), + inputChars: text.length, + url: cleanUrl, + }, + "Summarize API call", + ); + + return { + summary, + model, + title, + sourceUrl: cleanUrl, + inputChars: text.length, + callNo: stats.totalCalls, + }; + }, +}; + +export type { SummarizeEngine, SummarizeMode, SummarizeResult }; diff --git a/src/services/translate/translate-service.ts b/src/services/translate/translate-service.ts new file mode 100644 index 0000000..fa0af6e --- /dev/null +++ b/src/services/translate/translate-service.ts @@ -0,0 +1,226 @@ +import { env } from "../../core/env.js"; +import { logger } from "../../core/logger.js"; +import { spawn } from "node:child_process"; + +type TranslateResult = { + translatedText: string; + engine: "deepl" | "deepl_web_cli" | "google_web_v2"; + inputChars: number; + chunkCount: number; +}; + +const MAX_INPUT_CHARS = 12000; +const CHUNK_SIZE = 1200; + +function normalizeText(input: string): string { + return String(input || "").trim(); +} + +function toErrorInfo(error: unknown): { message: string; stack?: string } { + if (error instanceof Error) { + return { message: error.message, stack: error.stack }; + } + return { message: String(error) }; +} + +function splitText(text: string, maxLen: number): string[] { + const out: string[] = []; + let rest = text; + while (rest.length > maxLen) { + const cut = Math.max( + rest.lastIndexOf("\n", maxLen), + rest.lastIndexOf(". ", maxLen), + rest.lastIndexOf("! ", maxLen), + rest.lastIndexOf("? ", maxLen), + ); + const idx = cut > 200 ? cut + 1 : maxLen; + out.push(rest.slice(0, idx).trim()); + rest = rest.slice(idx).trim(); + } + if (rest) out.push(rest); + return out; +} + +async function translateGoogleWebV2(text: string, source: string, target: string): Promise { + const endpoint = new URL("https://translate.google.com/translate_a/single"); + endpoint.searchParams.set("q", text); + endpoint.searchParams.set("sl", source); + endpoint.searchParams.set("tl", target); + endpoint.searchParams.set("hl", "ko-KR"); + endpoint.searchParams.set("ie", "UTF-8"); + endpoint.searchParams.set("oe", "UTF-8"); + endpoint.searchParams.set("client", "at"); + for (const dt of ["t", "ld", "qca", "rm", "bd", "md", "ss", "ex", "sos"]) { + endpoint.searchParams.append("dt", dt); + } + + const response = await fetch(endpoint.toString(), { + headers: { + "User-Agent": "GoogleTranslate/6.27.0.08.415126308 (Linux; Android 7.1.2; Pixel 2 XL)", + Accept: "application/json,text/plain,*/*", + }, + }); + + if (!response.ok) { + throw new Error(`google_web_v2 μ‹€νŒ¨: HTTP ${response.status}`); + } + + const data = (await response.json()) as any; + const parts = Array.isArray(data?.[0]) ? data[0] : []; + const translated = parts + .map((item: any) => String(item?.[0] || "")) + .join("") + .trim(); + + if (!translated) { + throw new Error("google_web_v2 응닡 νŒŒμ‹± μ‹€νŒ¨"); + } + return translated; +} + +async function translateDeepL(text: string, source: string, target: string): Promise { + if (!env.DEEPL_API_KEY) { + throw new Error("DEEPL_API_KEY μ—†μŒ"); + } + + const params = new URLSearchParams(); + params.append("text", text); + params.append("target_lang", target.toUpperCase()); + if (source.toLowerCase() !== "auto") { + params.append("source_lang", source.toUpperCase()); + } + + const response = await fetch("https://api-free.deepl.com/v2/translate", { + method: "POST", + headers: { + Authorization: `DeepL-Auth-Key ${env.DEEPL_API_KEY}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + body: params.toString(), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`deepl μ‹€νŒ¨: HTTP ${response.status} ${body.slice(0, 120)}`); + } + + const data = (await response.json()) as any; + const translated = String(data?.translations?.[0]?.text || "").trim(); + if (!translated) { + throw new Error("deepl 응닡 νŒŒμ‹± μ‹€νŒ¨"); + } + return translated; +} + +async function runCliWithStdin( + bin: string, + args: string[], + input: string, + timeoutMs: number, +): Promise { + return await new Promise((resolve, reject) => { + const child = spawn(bin, args, { + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let killedByTimeout = false; + + const timer = setTimeout(() => { + killedByTimeout = true; + child.kill("SIGKILL"); + }, timeoutMs); + + child.stdout.on("data", (buf) => { + stdout += String(buf); + }); + child.stderr.on("data", (buf) => { + stderr += String(buf); + }); + child.on("error", (error) => { + clearTimeout(timer); + reject(error); + }); + child.on("close", (code) => { + clearTimeout(timer); + if (killedByTimeout) { + reject(new Error(`deepl-cli timeout ${timeoutMs}ms`)); + return; + } + if (code !== 0) { + reject(new Error(`deepl-cli exit=${code} ${stderr.slice(0, 160)}`)); + return; + } + resolve(stdout.trim()); + }); + + child.stdin.write(input); + child.stdin.end(); + }); +} + +async function translateDeepLCli(text: string, source: string, target: string): Promise { + if (source === "auto") { + throw new Error("deepl-cliλŠ” source=autoλ₯Ό μ§€μ›ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€. source μ–Έμ–΄λ₯Ό λͺ…μ‹œν•˜μ„Έμš”."); + } + const args = ["-s", "-F", source, "-T", target, "-t", String(Math.min(env.DEEPL_CLI_TIMEOUT_MS, 30000))]; + const output = await runCliWithStdin(env.DEEPL_CLI_BIN, args, text, env.DEEPL_CLI_TIMEOUT_MS); + if (!output) { + throw new Error("deepl-cli 응닡 λΉ„μ–΄μžˆμŒ"); + } + return output; +} + +export const translateService = { + getMaxInputChars() { + return MAX_INPUT_CHARS; + }, + + async translate(text: string, source = "auto", target = "ko"): Promise { + const input = normalizeText(text); + if (!input) throw new Error("λ²ˆμ—­ν•  ν…μŠ€νŠΈκ°€ λΉ„μ—ˆμŠ΅λ‹ˆλ‹€."); + if (input.length > MAX_INPUT_CHARS) { + throw new Error(`μž…λ ₯ 길이 초과: ${input.length}자 (μ΅œλŒ€ ${MAX_INPUT_CHARS}자)`); + } + + const sourceNorm = String(source || "auto").toLowerCase(); + const targetNorm = String(target || "ko").toLowerCase(); + const chunks = splitText(input, CHUNK_SIZE); + + const engines: Array<{ + name: "deepl" | "deepl_web_cli" | "google_web_v2"; + fn: (chunk: string, source: string, target: string) => Promise; + enabled: boolean; + }> = [ + { name: "deepl", fn: translateDeepL, enabled: Boolean(env.DEEPL_API_KEY) }, + { name: "deepl_web_cli", fn: translateDeepLCli, enabled: sourceNorm !== "auto" }, + { name: "google_web_v2", fn: translateGoogleWebV2, enabled: true }, + ]; + + for (const engine of engines) { + if (!engine.enabled) continue; + try { + const outputs: string[] = []; + for (const chunk of chunks) { + outputs.push(await engine.fn(chunk, sourceNorm, targetNorm)); + } + return { + translatedText: outputs.join("\n").trim(), + engine: engine.name, + inputChars: input.length, + chunkCount: chunks.length, + }; + } catch (error) { + const err = toErrorInfo(error); + logger.warn( + { engine: engine.name, errorMessage: err.message, errorStack: err.stack }, + "Translate engine failed, fallback next", + ); + } + } + + throw new Error("λͺ¨λ“  λ²ˆμ—­ 엔진이 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."); + }, +}; + +export type { TranslateResult }; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..abd07fc --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +}