diff --git a/.github/workflows/Github-Action-CD.yml b/.github/workflows/Github-Action-CD.yml index 6ac42a4..4785245 100644 --- a/.github/workflows/Github-Action-CD.yml +++ b/.github/workflows/Github-Action-CD.yml @@ -11,6 +11,14 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Detect changed paths + id: changes + uses: dorny/paths-filter@v3 + with: + filters: | + bot: + - 'discord-bot/**' + - name: Cache gradle uses: actions/setup-java@v4 with: @@ -29,9 +37,13 @@ jobs: - name: Build with Gradle Wrapper run: ./gradlew build - - name: Build Docker Image - run: + - name: Build Docker Images (Spring) + run: | docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_IMAGE_NAME }} . + - name: Build Docker Image (Discord Bot) + if: steps.changes.outputs.bot == 'true' + run: | + docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_BOT_IMAGE_NAME }}:latest ./discord-bot - name: Login to Docker Hub uses: docker/login-action@v3 @@ -39,9 +51,14 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Publish Docker Image to Docker Hub - run: - docker push ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_IMAGE_NAME }} + - name: Publish Docker Image to Docker Hub (Spring) + run: | + docker push ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_IMAGE_NAME }}:latest + + - name: Publish Docker Image to Docker Hub (Discord Bot) + if: steps.changes.outputs.bot == 'true' + run: | + docker push ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_BOT_IMAGE_NAME }}:latest - name: Upload Flyway migrations to EC2 uses: appleboy/scp-action@v0.1.7 @@ -54,6 +71,26 @@ jobs: target: "/home/ubuntu/migrations/" strip_components: 4 + - name: Upload docker compose files to EC2 (overwrite) + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USERNAME }} + key: ${{ secrets.SSH_PASSWORD }} + port: ${{ secrets.SSH_PORT }} + source: "docker-compose.yml" + target: "/home/ubuntu/" + + - name: Upload deploy script to EC2 (overwrite) + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USERNAME }} + key: ${{ secrets.SSH_PASSWORD }} + port: ${{ secrets.SSH_PORT }} + source: "scripts/deploy.sh" + target: "/home/ubuntu/" + strip_components: 1 - name: executing remote ssh commands using password uses: appleboy/ssh-action@v1.2.2 @@ -63,6 +100,9 @@ jobs: key: ${{ secrets.SSH_PASSWORD }} port: ${{ secrets.SSH_PORT }} script: | + echo "๐Ÿ”ง Ensuring deploy.sh is executable" + sudo chmod +x /home/ubuntu/deploy.sh + echo "๐Ÿ“„ Writing /home/ubuntu/.env" sudo bash -c 'cat > /home/ubuntu/.env << EOF DISCORD_CLIENT_ID=${{ secrets.DISCORD_CLIENT_ID }} @@ -81,6 +121,9 @@ jobs: PROD_DB_URL=${{ secrets.PROD_DB_URL }} PROD_DB_USERNAME=${{ secrets.PROD_DB_USERNAME }} PROD_DB_PASSWORD=${{ secrets.PROD_DB_PASSWORD }} + + SQS_QUEUE_URL=${{ secrets.SQS_QUEUE_URL }} + SQS_QUEUE_NAME=${{ secrets.SQS_QUEUE_NAME }} EOF' echo "๐Ÿ›ซ Running Flyway migrations (one-off)" @@ -98,5 +141,4 @@ jobs: -password="$PROD_DB_PASSWORD" \ migrate - sudo /home/ubuntu/deploy.sh \ No newline at end of file diff --git a/build.gradle b/build.gradle index 613fae9..c8ca0e0 100644 --- a/build.gradle +++ b/build.gradle @@ -29,7 +29,7 @@ repositories { } ext { - set('springCloudVersion', "2024.0.0") + set('springCloudVersion', "2024.0.3") } dependencies { @@ -60,7 +60,8 @@ dependencies { implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' // AWS - implementation "org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE" + implementation "io.awspring.cloud:spring-cloud-aws-starter-s3" + implementation "io.awspring.cloud:spring-cloud-aws-starter-sqs" // Prometheus implementation 'io.micrometer:micrometer-registry-prometheus' @@ -74,6 +75,7 @@ dependencies { dependencyManagement { imports { mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" + mavenBom "io.awspring.cloud:spring-cloud-aws-dependencies:3.3.1" } } diff --git a/discord-bot/.gitignore b/discord-bot/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/discord-bot/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/discord-bot/Dockerfile b/discord-bot/Dockerfile new file mode 100644 index 0000000..0998c9e --- /dev/null +++ b/discord-bot/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine + +WORKDIR /app + +# ์˜์กด์„ฑ ์„ค์น˜ +COPY package*.json ./ +RUN npm ci --omit=dev + +# ์†Œ์Šค ๋ณต์‚ฌ +COPY . . + +# ์‹คํ–‰ +CMD ["node", "index.js"] \ No newline at end of file diff --git a/discord-bot/bot.js b/discord-bot/bot.js new file mode 100644 index 0000000..8ec73b9 --- /dev/null +++ b/discord-bot/bot.js @@ -0,0 +1,106 @@ +/** + * Discord Bot (Producer) + * - Discord Gateway(WebSocket)์—์„œ ๋ฉค๋ฒ„ ์—…๋ฐ์ดํŠธ ์ด๋ฒคํŠธ๋ฅผ ์ˆ˜์‹  + * - ์—ญํ• (Role) ๋ณ€๊ฒฝ์„ ๊ฐ์ง€ํ•˜์—ฌ added/removed diff๋ฅผ ๊ณ„์‚ฐ + * - ๊ณ„์‚ฐ๋œ ์ด๋ฒคํŠธ๋ฅผ SQS๋กœ ๋ฐœํ–‰(SendMessage)ํ•˜์—ฌ ๋น„๋™๊ธฐ ์ฒ˜๋ฆฌ ํŒŒ์ดํ”„๋ผ์ธ์„ ์‹œ์ž‘ + * + * ํ•„์š”ํ•œ ํ™˜๊ฒฝ๋ณ€์ˆ˜(.env): + * - DISCORD_BOT_TOKEN: Discord Bot ํ† ํฐ + * - SQS_QUEUE_URL: ๋ฐœํ–‰ ๋Œ€์ƒ SQS Queue URL + * - LOG_LEVEL + */ + +// ์—ญํ•  ๋ณ€๊ฒฝ ๊ฐ์ง€ + diff ๊ณ„์‚ฐ +import { Client, GatewayIntentBits, Partials } from "discord.js"; +import { v4 as uuidv4 } from "uuid"; +import pino from "pino"; +import { publishRoleChangeEvent } from "./sqsProducer.js"; + +const log = pino({ level: process.env.LOG_LEVEL || "info" }); + +const client = new Client({ + intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers], + partials: [Partials.GuildMember], // ์ผ๋ถ€ ์ด๋ฒคํŠธ์—์„œ member ์ •๋ณด๊ฐ€ partial๋กœ ๋“ค์–ด์˜ฌ ์ˆ˜ ์žˆ์–ด fetch()๋กœ ๋ณด๊ฐ• +}); + +// oldMember/newMember์˜ ์—ญํ•  ๋ชฉ๋ก์„ ๋น„๊ตํ•ด "์ถ”๊ฐ€๋œ ์—ญํ• "๊ณผ "์‚ญ์ œ๋œ ์—ญํ• "์„ ๊ณ„์‚ฐ +// SQS์—๋Š” diff๋งŒ ๋ณด๋‚ด์„œ ๋ฉ”์‹œ์ง€๋ฅผ ๊ฐ€๋ณ๊ฒŒ ์œ ์ง€ +function diffRoles(oldMember, newMember) { + const oldSet = new Set(oldMember.roles.cache.keys()); + const newSet = new Set(newMember.roles.cache.keys()); + + //@everyone ์—ญํ• ์€ diff ๊ณ„์‚ฐ์—์„œ ์ œ์™ธ : @everyone ์—ญํ• ์€ role id๊ฐ€ guild id์™€ ๋™์ผ + oldSet.delete(oldMember.guild.id); + newSet.delete(newMember.guild.id); + + const added = []; + const removed = []; + + for (const r of newSet) if (!oldSet.has(r)) added.push(r); + for (const r of oldSet) if (!newSet.has(r)) removed.push(r); + + return { added, removed }; +} + +client.on("ready", () => { + log.info({ botUser: client.user?.tag }, "Discord bot ready"); +}); + +// KST(Asia/Seoul) ๊ธฐ์ค€์œผ๋กœ "YYYY-MM-DDTHH:mm:ss" ํ˜•ํƒœ์˜ ๋ฌธ์ž์—ด์„ ์ƒ์„ฑ +function nowKstLocalDateTimeString() { + const now = new Date(); + // sv-SE ํฌ๋งท์€ 24์‹œ๊ฐ„์ œ "YYYY-MM-DD HH:mm:ss" ํ˜•ํƒœ๋กœ ์•ˆ์ •์ ์œผ๋กœ ์ถœ๋ ฅ๋˜์–ด ๊ฐ€๊ณต์ด ์‰ฝ์Šต๋‹ˆ๋‹ค. + const kst = new Intl.DateTimeFormat("sv-SE", { + timeZone: "Asia/Seoul", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }).format(now); + + return kst.replace(" ", "T"); +} + + +client.on("guildMemberUpdate", async (oldMember, newMember) => { + try { + // ์ด๋ฒคํŠธ payload๊ฐ€ ๋ถˆ์™„์ „ํ•˜๊ฒŒ ๋“ค์–ด์˜ค๋Š” ๊ฒฝ์šฐ๊ฐ€ ์žˆ์–ด ํ•„์š”ํ•œ ์ •๋ณด๋ฅผ fetch๋กœ ๋ณด๊ฐ• + // ๋„คํŠธ์›Œํฌ ํ˜ธ์ถœ์ด๋ฏ€๋กœ ์‹คํŒจ ๊ฐ€๋Šฅ โ†’ try/catch๋กœ ๊ฐ์‹ธ๊ธฐ + if (oldMember.partial) oldMember = await oldMember.fetch(); + if (newMember.partial) newMember = await newMember.fetch(); + + const { added, removed } = diffRoles(oldMember, newMember); + // ์—ญํ•  ๋ณ€ํ™”๊ฐ€ ์—†์œผ๋ฉด SQS๋กœ ๋ฐœํ–‰ํ•˜์ง€ ์•Š์Œ + if (added.length === 0 && removed.length === 0) return; + + // SQS๋กœ ๋ฐœํ–‰ํ•  ์ด๋ฒคํŠธ ๋ฉ”์‹œ์ง€ + // - eventId: ์ค‘๋ณต ๋ฐœํ–‰/์žฌ์‹œ๋„ ์ƒํ™ฉ์—์„œ ์†Œ๋น„์ž๊ฐ€ ๋ฉฑ๋“ฑ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ๋„๋ก UUID ๋ถ€์—ฌ + const payload = { + eventType: "DISCORD_ROLE_CHANGED", + eventId: uuidv4(), + occurredAt: nowKstLocalDateTimeString(), + guildId: newMember.guild.id, + discordUserId: newMember.user.id, + discordLoginId: newMember.user.username, + addedRoleIds: added, + removedRoleIds: removed, + source: "discord-bot", + schemaVersion: 1, + }; + + log.info( + { eventId: payload.eventId, discordUserId: payload.discordUserId, added, removed }, + "Role change detected" + ); + + await publishRoleChangeEvent(payload); + } catch (err) { + log.error({ err }, "Failed handling guildMemberUpdate"); + } +}); + +// Discord Gateway ๋กœ๊ทธ์ธ (์‹คํŒจ ์‹œ ํ† ํฐ/์ธํ…ํŠธ/๋„คํŠธ์›Œํฌ ์„ค์ •์„ ์ ๊ฒ€ํ•˜์„ธ์š”) +await client.login(process.env.DISCORD_BOT_TOKEN); \ No newline at end of file diff --git a/discord-bot/index.js b/discord-bot/index.js new file mode 100644 index 0000000..e46e848 --- /dev/null +++ b/discord-bot/index.js @@ -0,0 +1 @@ +import "./bot.js"; diff --git a/discord-bot/package-lock.json b/discord-bot/package-lock.json new file mode 100644 index 0000000..3370598 --- /dev/null +++ b/discord-bot/package-lock.json @@ -0,0 +1,1760 @@ +{ + "name": "discord-bot", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "discord-bot", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@aws-sdk/client-sqs": "^3.960.0", + "discord.js": "^14.25.1", + "pino": "^10.1.0", + "uuid": "^13.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-sqs": { + "version": "3.960.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sqs/-/client-sqs-3.960.0.tgz", + "integrity": "sha512-c5QYFPc90Sbl1/Lr622Y3dfe+qPGcmqh0rIplZxA/f55NZGVhoEmLaF/qu3YlzbSlU2fieBzadsQwN8TGuK12w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.957.0", + "@aws-sdk/credential-provider-node": "3.958.0", + "@aws-sdk/middleware-host-header": "3.957.0", + "@aws-sdk/middleware-logger": "3.957.0", + "@aws-sdk/middleware-recursion-detection": "3.957.0", + "@aws-sdk/middleware-sdk-sqs": "3.957.0", + "@aws-sdk/middleware-user-agent": "3.957.0", + "@aws-sdk/region-config-resolver": "3.957.0", + "@aws-sdk/types": "3.957.0", + "@aws-sdk/util-endpoints": "3.957.0", + "@aws-sdk/util-user-agent-browser": "3.957.0", + "@aws-sdk/util-user-agent-node": "3.957.0", + "@smithy/config-resolver": "^4.4.5", + "@smithy/core": "^3.20.0", + "@smithy/fetch-http-handler": "^5.3.8", + "@smithy/hash-node": "^4.2.7", + "@smithy/invalid-dependency": "^4.2.7", + "@smithy/md5-js": "^4.2.7", + "@smithy/middleware-content-length": "^4.2.7", + "@smithy/middleware-endpoint": "^4.4.1", + "@smithy/middleware-retry": "^4.4.17", + "@smithy/middleware-serde": "^4.2.8", + "@smithy/middleware-stack": "^4.2.7", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/node-http-handler": "^4.4.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/smithy-client": "^4.10.2", + "@smithy/types": "^4.11.0", + "@smithy/url-parser": "^4.2.7", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.16", + "@smithy/util-defaults-mode-node": "^4.2.19", + "@smithy/util-endpoints": "^3.2.7", + "@smithy/util-middleware": "^4.2.7", + "@smithy/util-retry": "^4.2.7", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sso": { + "version": "3.958.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.958.0.tgz", + "integrity": "sha512-6qNCIeaMzKzfqasy2nNRuYnMuaMebCcCPP4J2CVGkA8QYMbIVKPlkn9bpB20Vxe6H/r3jtCCLQaOJjVTx/6dXg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.957.0", + "@aws-sdk/middleware-host-header": "3.957.0", + "@aws-sdk/middleware-logger": "3.957.0", + "@aws-sdk/middleware-recursion-detection": "3.957.0", + "@aws-sdk/middleware-user-agent": "3.957.0", + "@aws-sdk/region-config-resolver": "3.957.0", + "@aws-sdk/types": "3.957.0", + "@aws-sdk/util-endpoints": "3.957.0", + "@aws-sdk/util-user-agent-browser": "3.957.0", + "@aws-sdk/util-user-agent-node": "3.957.0", + "@smithy/config-resolver": "^4.4.5", + "@smithy/core": "^3.20.0", + "@smithy/fetch-http-handler": "^5.3.8", + "@smithy/hash-node": "^4.2.7", + "@smithy/invalid-dependency": "^4.2.7", + "@smithy/middleware-content-length": "^4.2.7", + "@smithy/middleware-endpoint": "^4.4.1", + "@smithy/middleware-retry": "^4.4.17", + "@smithy/middleware-serde": "^4.2.8", + "@smithy/middleware-stack": "^4.2.7", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/node-http-handler": "^4.4.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/smithy-client": "^4.10.2", + "@smithy/types": "^4.11.0", + "@smithy/url-parser": "^4.2.7", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.16", + "@smithy/util-defaults-mode-node": "^4.2.19", + "@smithy/util-endpoints": "^3.2.7", + "@smithy/util-middleware": "^4.2.7", + "@smithy/util-retry": "^4.2.7", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.957.0.tgz", + "integrity": "sha512-DrZgDnF1lQZv75a52nFWs6MExihJF2GZB6ETZRqr6jMwhrk2kbJPUtvgbifwcL7AYmVqHQDJBrR/MqkwwFCpiw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.957.0", + "@aws-sdk/xml-builder": "3.957.0", + "@smithy/core": "^3.20.0", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/property-provider": "^4.2.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/signature-v4": "^5.3.7", + "@smithy/smithy-client": "^4.10.2", + "@smithy/types": "^4.11.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-middleware": "^4.2.7", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.957.0.tgz", + "integrity": "sha512-475mkhGaWCr+Z52fOOVb/q2VHuNvqEDixlYIkeaO6xJ6t9qR0wpLt4hOQaR6zR1wfZV0SlE7d8RErdYq/PByog==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.957.0", + "@aws-sdk/types": "3.957.0", + "@smithy/property-provider": "^4.2.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.957.0.tgz", + "integrity": "sha512-8dS55QHRxXgJlHkEYaCGZIhieCs9NU1HU1BcqQ4RfUdSsfRdxxktqUKgCnBnOOn0oD3PPA8cQOCAVgIyRb3Rfw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.957.0", + "@aws-sdk/types": "3.957.0", + "@smithy/fetch-http-handler": "^5.3.8", + "@smithy/node-http-handler": "^4.4.7", + "@smithy/property-provider": "^4.2.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/smithy-client": "^4.10.2", + "@smithy/types": "^4.11.0", + "@smithy/util-stream": "^4.5.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.958.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.958.0.tgz", + "integrity": "sha512-u7twvZa1/6GWmPBZs6DbjlegCoNzNjBsMS/6fvh5quByYrcJr/uLd8YEr7S3UIq4kR/gSnHqcae7y2nL2bqZdg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.957.0", + "@aws-sdk/credential-provider-env": "3.957.0", + "@aws-sdk/credential-provider-http": "3.957.0", + "@aws-sdk/credential-provider-login": "3.958.0", + "@aws-sdk/credential-provider-process": "3.957.0", + "@aws-sdk/credential-provider-sso": "3.958.0", + "@aws-sdk/credential-provider-web-identity": "3.958.0", + "@aws-sdk/nested-clients": "3.958.0", + "@aws-sdk/types": "3.957.0", + "@smithy/credential-provider-imds": "^4.2.7", + "@smithy/property-provider": "^4.2.7", + "@smithy/shared-ini-file-loader": "^4.4.2", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.958.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.958.0.tgz", + "integrity": "sha512-sDwtDnBSszUIbzbOORGh5gmXGl9aK25+BHb4gb1aVlqB+nNL2+IUEJA62+CE55lXSH8qXF90paivjK8tOHTwPA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.957.0", + "@aws-sdk/nested-clients": "3.958.0", + "@aws-sdk/types": "3.957.0", + "@smithy/property-provider": "^4.2.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/shared-ini-file-loader": "^4.4.2", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.958.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.958.0.tgz", + "integrity": "sha512-vdoZbNG2dt66I7EpN3fKCzi6fp9xjIiwEA/vVVgqO4wXCGw8rKPIdDUus4e13VvTr330uQs2W0UNg/7AgtquEQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.957.0", + "@aws-sdk/credential-provider-http": "3.957.0", + "@aws-sdk/credential-provider-ini": "3.958.0", + "@aws-sdk/credential-provider-process": "3.957.0", + "@aws-sdk/credential-provider-sso": "3.958.0", + "@aws-sdk/credential-provider-web-identity": "3.958.0", + "@aws-sdk/types": "3.957.0", + "@smithy/credential-provider-imds": "^4.2.7", + "@smithy/property-provider": "^4.2.7", + "@smithy/shared-ini-file-loader": "^4.4.2", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.957.0.tgz", + "integrity": "sha512-/KIz9kadwbeLy6SKvT79W81Y+hb/8LMDyeloA2zhouE28hmne+hLn0wNCQXAAupFFlYOAtZR2NTBs7HBAReJlg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.957.0", + "@aws-sdk/types": "3.957.0", + "@smithy/property-provider": "^4.2.7", + "@smithy/shared-ini-file-loader": "^4.4.2", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.958.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.958.0.tgz", + "integrity": "sha512-CBYHJ5ufp8HC4q+o7IJejCUctJXWaksgpmoFpXerbjAso7/Fg7LLUu9inXVOxlHKLlvYekDXjIUBXDJS2WYdgg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.958.0", + "@aws-sdk/core": "3.957.0", + "@aws-sdk/token-providers": "3.958.0", + "@aws-sdk/types": "3.957.0", + "@smithy/property-provider": "^4.2.7", + "@smithy/shared-ini-file-loader": "^4.4.2", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.958.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.958.0.tgz", + "integrity": "sha512-dgnvwjMq5Y66WozzUzxNkCFap+umHUtqMMKlr8z/vl9NYMLem/WUbWNpFFOVFWquXikc+ewtpBMR4KEDXfZ+KA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.957.0", + "@aws-sdk/nested-clients": "3.958.0", + "@aws-sdk/types": "3.957.0", + "@smithy/property-provider": "^4.2.7", + "@smithy/shared-ini-file-loader": "^4.4.2", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.957.0.tgz", + "integrity": "sha512-BBgKawVyfQZglEkNTuBBdC3azlyqNXsvvN4jPkWAiNYcY0x1BasaJFl+7u/HisfULstryweJq/dAvIZIxzlZaA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.957.0", + "@smithy/protocol-http": "^5.3.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.957.0.tgz", + "integrity": "sha512-w1qfKrSKHf9b5a8O76yQ1t69u6NWuBjr5kBX+jRWFx/5mu6RLpqERXRpVJxfosbep7k3B+DSB5tZMZ82GKcJtQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.957.0", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.957.0.tgz", + "integrity": "sha512-D2H/WoxhAZNYX+IjkKTdOhOkWQaK0jjJrDBj56hKjU5c9ltQiaX/1PqJ4dfjHntEshJfu0w+E6XJ+/6A6ILBBA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.957.0", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/protocol-http": "^5.3.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-sqs": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-sqs/-/middleware-sdk-sqs-3.957.0.tgz", + "integrity": "sha512-3A1V2oSV/NzWukwDBwnf/ng+n+8zU32jRml0lbYiP9PzBgc6D6Y4Z/RCbPp7g+PO8XrCRrZg6QKspO3cLpGnOw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.957.0", + "@smithy/smithy-client": "^4.10.2", + "@smithy/types": "^4.11.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.957.0.tgz", + "integrity": "sha512-50vcHu96XakQnIvlKJ1UoltrFODjsq2KvtTgHiPFteUS884lQnK5VC/8xd1Msz/1ONpLMzdCVproCQqhDTtMPQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.957.0", + "@aws-sdk/types": "3.957.0", + "@aws-sdk/util-endpoints": "3.957.0", + "@smithy/core": "^3.20.0", + "@smithy/protocol-http": "^5.3.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.958.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.958.0.tgz", + "integrity": "sha512-/KuCcS8b5TpQXkYOrPLYytrgxBhv81+5pChkOlhegbeHttjM69pyUpQVJqyfDM/A7wPLnDrzCAnk4zaAOkY0Nw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.957.0", + "@aws-sdk/middleware-host-header": "3.957.0", + "@aws-sdk/middleware-logger": "3.957.0", + "@aws-sdk/middleware-recursion-detection": "3.957.0", + "@aws-sdk/middleware-user-agent": "3.957.0", + "@aws-sdk/region-config-resolver": "3.957.0", + "@aws-sdk/types": "3.957.0", + "@aws-sdk/util-endpoints": "3.957.0", + "@aws-sdk/util-user-agent-browser": "3.957.0", + "@aws-sdk/util-user-agent-node": "3.957.0", + "@smithy/config-resolver": "^4.4.5", + "@smithy/core": "^3.20.0", + "@smithy/fetch-http-handler": "^5.3.8", + "@smithy/hash-node": "^4.2.7", + "@smithy/invalid-dependency": "^4.2.7", + "@smithy/middleware-content-length": "^4.2.7", + "@smithy/middleware-endpoint": "^4.4.1", + "@smithy/middleware-retry": "^4.4.17", + "@smithy/middleware-serde": "^4.2.8", + "@smithy/middleware-stack": "^4.2.7", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/node-http-handler": "^4.4.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/smithy-client": "^4.10.2", + "@smithy/types": "^4.11.0", + "@smithy/url-parser": "^4.2.7", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.16", + "@smithy/util-defaults-mode-node": "^4.2.19", + "@smithy/util-endpoints": "^3.2.7", + "@smithy/util-middleware": "^4.2.7", + "@smithy/util-retry": "^4.2.7", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.957.0.tgz", + "integrity": "sha512-V8iY3blh8l2iaOqXWW88HbkY5jDoWjH56jonprG/cpyqqCnprvpMUZWPWYJoI8rHRf2bqzZeql1slxG6EnKI7A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.957.0", + "@smithy/config-resolver": "^4.4.5", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.958.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.958.0.tgz", + "integrity": "sha512-UCj7lQXODduD1myNJQkV+LYcGYJ9iiMggR8ow8Hva1g3A/Na5imNXzz6O67k7DAee0TYpy+gkNw+SizC6min8Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.957.0", + "@aws-sdk/nested-clients": "3.958.0", + "@aws-sdk/types": "3.957.0", + "@smithy/property-provider": "^4.2.7", + "@smithy/shared-ini-file-loader": "^4.4.2", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.957.0.tgz", + "integrity": "sha512-wzWC2Nrt859ABk6UCAVY/WYEbAd7FjkdrQL6m24+tfmWYDNRByTJ9uOgU/kw9zqLCAwb//CPvrJdhqjTznWXAg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.957.0.tgz", + "integrity": "sha512-xwF9K24mZSxcxKS3UKQFeX/dPYkEps9wF1b+MGON7EvnbcucrJGyQyK1v1xFPn1aqXkBTFi+SZaMRx5E5YCVFw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.957.0", + "@smithy/types": "^4.11.0", + "@smithy/url-parser": "^4.2.7", + "@smithy/util-endpoints": "^3.2.7", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.957.0.tgz", + "integrity": "sha512-nhmgKHnNV9K+i9daumaIz8JTLsIIML9PE/HUks5liyrjUzenjW/aHoc7WJ9/Td/gPZtayxFnXQSJRb/fDlBuJw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.957.0.tgz", + "integrity": "sha512-exueuwxef0lUJRnGaVkNSC674eAiWU07ORhxBnevFFZEKisln+09Qrtw823iyv5I1N8T+wKfh95xvtWQrNKNQw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.957.0", + "@smithy/types": "^4.11.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.957.0.tgz", + "integrity": "sha512-ycbYCwqXk4gJGp0Oxkzf2KBeeGBdTxz559D41NJP8FlzSej1Gh7Rk40Zo6AyTfsNWkrl/kVi1t937OIzC5t+9Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "3.957.0", + "@aws-sdk/types": "3.957.0", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.957.0.tgz", + "integrity": "sha512-Ai5iiQqS8kJ5PjzMhWcLKN0G2yasAkvpnPlq2EnqlIMdB48HsizElt62qcktdxp4neRMyGkFq4NzgmDbXnhRiA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "fast-xml-parser": "5.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.2.tgz", + "integrity": "sha512-C0NBLsIqzDIae8HFw9YIrIBsbc0xTiOtt7fAukGPnqQ/+zZNaq+4jhuccltK0QuWHBnNm/a6kLIRA6GFiM10eg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "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/@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/@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/@smithy/abort-controller": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.7.tgz", + "integrity": "sha512-rzMY6CaKx2qxrbYbqjXWS0plqEy7LOdKHS0bg4ixJ6aoGDPNUcLWk/FRNuCILh7GKLG9TFUXYYeQQldMBBwuyw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.5.tgz", + "integrity": "sha512-HAGoUAFYsUkoSckuKbCPayECeMim8pOu+yLy1zOxt1sifzEbrsRpYa+mKcMdiHKMeiqOibyPG0sFJnmaV/OGEg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.7", + "@smithy/types": "^4.11.0", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-endpoints": "^3.2.7", + "@smithy/util-middleware": "^4.2.7", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.20.0.tgz", + "integrity": "sha512-WsSHCPq/neD5G/MkK4csLI5Y5Pkd9c1NMfpYEKeghSGaD4Ja1qLIohRQf2D5c1Uy5aXp76DeKHkzWZ9KAlHroQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/middleware-serde": "^4.2.8", + "@smithy/protocol-http": "^5.3.7", + "@smithy/types": "^4.11.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-middleware": "^4.2.7", + "@smithy/util-stream": "^4.5.8", + "@smithy/util-utf8": "^4.2.0", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.7.tgz", + "integrity": "sha512-CmduWdCiILCRNbQWFR0OcZlUPVtyE49Sr8yYL0rZQ4D/wKxiNzBNS/YHemvnbkIWj623fplgkexUd/c9CAKdoA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.7", + "@smithy/property-provider": "^4.2.7", + "@smithy/types": "^4.11.0", + "@smithy/url-parser": "^4.2.7", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.8.tgz", + "integrity": "sha512-h/Fi+o7mti4n8wx1SR6UHWLaakwHRx29sizvp8OOm7iqwKGFneT06GCSFhml6Bha5BT6ot5pj3CYZnCHhGC2Rg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.7", + "@smithy/querystring-builder": "^4.2.7", + "@smithy/types": "^4.11.0", + "@smithy/util-base64": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.7.tgz", + "integrity": "sha512-PU/JWLTBCV1c8FtB8tEFnY4eV1tSfBc7bDBADHfn1K+uRbPgSJ9jnJp0hyjiFN2PMdPzxsf1Fdu0eo9fJ760Xw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.7.tgz", + "integrity": "sha512-ncvgCr9a15nPlkhIUx3CU4d7E7WEuVJOV7fS7nnK2hLtPK9tYRBkMHQbhXU1VvvKeBm/O0x26OEoBq+ngFpOEQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz", + "integrity": "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/md5-js": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.7.tgz", + "integrity": "sha512-Wv6JcUxtOLTnxvNjDnAiATUsk8gvA6EeS8zzHig07dotpByYsLot+m0AaQEniUBjx97AC41MQR4hW0baraD1Xw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.7.tgz", + "integrity": "sha512-GszfBfCcvt7kIbJ41LuNa5f0wvQCHhnGx/aDaZJCCT05Ld6x6U2s0xsc/0mBFONBZjQJp2U/0uSJ178OXOwbhg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.1.tgz", + "integrity": "sha512-gpLspUAoe6f1M6H0u4cVuFzxZBrsGZmjx2O9SigurTx4PbntYa4AJ+o0G0oGm1L2oSX6oBhcGHwrfJHup2JnJg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.20.0", + "@smithy/middleware-serde": "^4.2.8", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/shared-ini-file-loader": "^4.4.2", + "@smithy/types": "^4.11.0", + "@smithy/url-parser": "^4.2.7", + "@smithy/util-middleware": "^4.2.7", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.4.17", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.17.tgz", + "integrity": "sha512-MqbXK6Y9uq17h+4r0ogu/sBT6V/rdV+5NvYL7ZV444BKfQygYe8wAhDrVXagVebN6w2RE0Fm245l69mOsPGZzg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/service-error-classification": "^4.2.7", + "@smithy/smithy-client": "^4.10.2", + "@smithy/types": "^4.11.0", + "@smithy/util-middleware": "^4.2.7", + "@smithy/util-retry": "^4.2.7", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.8.tgz", + "integrity": "sha512-8rDGYen5m5+NV9eHv9ry0sqm2gI6W7mc1VSFMtn6Igo25S507/HaOX9LTHAS2/J32VXD0xSzrY0H5FJtOMS4/w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.7.tgz", + "integrity": "sha512-bsOT0rJ+HHlZd9crHoS37mt8qRRN/h9jRve1SXUhVbkRzu0QaNYZp1i1jha4n098tsvROjcwfLlfvcFuJSXEsw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.7.tgz", + "integrity": "sha512-7r58wq8sdOcrwWe+klL9y3bc4GW1gnlfnFOuL7CXa7UzfhzhxKuzNdtqgzmTV+53lEp9NXh5hY/S4UgjLOzPfw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.7", + "@smithy/shared-ini-file-loader": "^4.4.2", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.4.7", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.7.tgz", + "integrity": "sha512-NELpdmBOO6EpZtWgQiHjoShs1kmweaiNuETUpuup+cmm/xJYjT4eUjfhrXRP4jCOaAsS3c3yPsP3B+K+/fyPCQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/querystring-builder": "^4.2.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.7.tgz", + "integrity": "sha512-jmNYKe9MGGPoSl/D7JDDs1C8b3dC8f/w78LbaVfoTtWy4xAd5dfjaFG9c9PWPihY4ggMQNQSMtzU77CNgAJwmA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.7.tgz", + "integrity": "sha512-1r07pb994I20dD/c2seaZhoCuNYm0rWrvBxhCQ70brNh11M5Ml2ew6qJVo0lclB3jMIXirD4s2XRXRe7QEi0xA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.7.tgz", + "integrity": "sha512-eKONSywHZxK4tBxe2lXEysh8wbBdvDWiA+RIuaxZSgCMmA0zMgoDpGLJhnyj+c0leOQprVnXOmcB4m+W9Rw7sg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "@smithy/util-uri-escape": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.7.tgz", + "integrity": "sha512-3X5ZvzUHmlSTHAXFlswrS6EGt8fMSIxX/c3Rm1Pni3+wYWB6cjGocmRIoqcQF9nU5OgGmL0u7l9m44tSUpfj9w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.7.tgz", + "integrity": "sha512-YB7oCbukqEb2Dlh3340/8g8vNGbs/QsNNRms+gv3N2AtZz9/1vSBx6/6tpwQpZMEJFs7Uq8h4mmOn48ZZ72MkA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.2.tgz", + "integrity": "sha512-M7iUUff/KwfNunmrgtqBfvZSzh3bmFgv/j/t1Y1dQ+8dNo34br1cqVEqy6v0mYEgi0DkGO7Xig0AnuOaEGVlcg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.7.tgz", + "integrity": "sha512-9oNUlqBlFZFOSdxgImA6X5GFuzE7V2H7VG/7E70cdLhidFbdtvxxt81EHgykGK5vq5D3FafH//X+Oy31j3CKOg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.0", + "@smithy/protocol-http": "^5.3.7", + "@smithy/types": "^4.11.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-middleware": "^4.2.7", + "@smithy/util-uri-escape": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.10.2.tgz", + "integrity": "sha512-D5z79xQWpgrGpAHb054Fn2CCTQZpog7JELbVQ6XAvXs5MNKWf28U9gzSBlJkOyMl9LA1TZEjRtwvGXfP0Sl90g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.20.0", + "@smithy/middleware-endpoint": "^4.4.1", + "@smithy/middleware-stack": "^4.2.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/types": "^4.11.0", + "@smithy/util-stream": "^4.5.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.11.0.tgz", + "integrity": "sha512-mlrmL0DRDVe3mNrjTcVcZEgkFmufITfUAPBEA+AHYiIeYyJebso/He1qLbP3PssRe22KUzLRpQSdBPbXdgZ2VA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.7.tgz", + "integrity": "sha512-/RLtVsRV4uY3qPWhBDsjwahAtt3x2IsMGnP5W1b2VZIe+qgCqkLxI1UOHDZp1Q1QSOrdOR32MF3Ph2JfWT1VHg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.2.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.0.tgz", + "integrity": "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz", + "integrity": "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.1.tgz", + "integrity": "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.0.tgz", + "integrity": "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz", + "integrity": "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.3.16", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.16.tgz", + "integrity": "sha512-/eiSP3mzY3TsvUOYMeL4EqUX6fgUOj2eUOU4rMMgVbq67TiRLyxT7Xsjxq0bW3OwuzK009qOwF0L2OgJqperAQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.7", + "@smithy/smithy-client": "^4.10.2", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.2.19", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.19.tgz", + "integrity": "sha512-3a4+4mhf6VycEJyHIQLypRbiwG6aJvbQAeRAVXydMmfweEPnLLabRbdyo/Pjw8Rew9vjsh5WCdhmDaHkQnhhhA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.4.5", + "@smithy/credential-provider-imds": "^4.2.7", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/property-provider": "^4.2.7", + "@smithy/smithy-client": "^4.10.2", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.7.tgz", + "integrity": "sha512-s4ILhyAvVqhMDYREeTS68R43B1V5aenV5q/V1QpRQJkCXib5BPRo4s7uNdzGtIKxaPHCfU/8YkvPAEvTpxgspg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz", + "integrity": "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.7.tgz", + "integrity": "sha512-i1IkpbOae6NvIKsEeLLM9/2q4X+M90KV3oCFgWQI4q0Qz+yUZvsr+gZPdAEAtFhWQhAHpTsJO8DRJPuwVyln+w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.7.tgz", + "integrity": "sha512-SvDdsQyF5CIASa4EYVT02LukPHVzAgUA4kMAuZ97QJc2BpAqZfA4PINB8/KOoCXEw9tsuv/jQjMeaHFvxdLNGg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.2.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "4.5.8", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.8.tgz", + "integrity": "sha512-ZnnBhTapjM0YPGUSmOs0Mcg/Gg87k503qG4zU2v/+Js2Gu+daKOJMeqcQns8ajepY8tgzzfYxl6kQyZKml6O2w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.3.8", + "@smithy/node-http-handler": "^4.4.7", + "@smithy/types": "^4.11.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz", + "integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz", + "integrity": "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/uuid": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.0.tgz", + "integrity": "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@types/node": { + "version": "25.0.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", + "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", + "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/bowser": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.13.1.tgz", + "integrity": "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==", + "license": "MIT" + }, + "node_modules/discord-api-types": { + "version": "0.38.37", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.37.tgz", + "integrity": "sha512-Cv47jzY1jkGkh5sv0bfHYqGgKOWO1peOrGMkDFM4UmaGMOTgOW8QSexhvixa9sVOiz8MnVOBryWYyw/CEVhj7w==", + "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/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-xml-parser": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", + "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^2.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "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/magic-bytes.js": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.12.1.tgz", + "integrity": "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA==", + "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/pino": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.1.0.tgz", + "integrity": "sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w==", + "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-std-serializers": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz", + "integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==", + "license": "MIT" + }, + "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/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/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/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/sonic-boom": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", + "integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==", + "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/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/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/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": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "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 + } + } + } + } +} diff --git a/discord-bot/package.json b/discord-bot/package.json new file mode 100644 index 0000000..c43a73c --- /dev/null +++ b/discord-bot/package.json @@ -0,0 +1,19 @@ +{ + "name": "discord-bot", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "module", + "dependencies": { + "@aws-sdk/client-sqs": "^3.960.0", + "discord.js": "^14.25.1", + "pino": "^10.1.0", + "uuid": "^13.0.0" + } +} diff --git a/discord-bot/sqsProducer.js b/discord-bot/sqsProducer.js new file mode 100644 index 0000000..edd9da0 --- /dev/null +++ b/discord-bot/sqsProducer.js @@ -0,0 +1,74 @@ +/** + * SQS Producer + * - Discord Bot์—์„œ ์ƒ์„ฑํ•œ ์—ญํ•  ๋ณ€๊ฒฝ ์ด๋ฒคํŠธ payload๋ฅผ SQS๋กœ ๋ฐœํ–‰(SendMessage)ํ•ฉ๋‹ˆ๋‹ค. + * - ๋„คํŠธ์›Œํฌ/์ผ์‹œ์  AWS ์˜ค๋ฅ˜์— ๋Œ€๋น„ํ•ด ๊ฐ„๋‹จํ•œ ์žฌ์‹œ๋„(์ง€์ˆ˜ ๋ฐฑ์˜คํ”„ + jitter)๋ฅผ ์ ์šฉํ•ฉ๋‹ˆ๋‹ค. + * - EC2์— IAM Role(Instance Profile)์ด ๋ถ™์–ด ์žˆ๋‹ค๋ฉด Access Key๋ฅผ ์ฝ”๋“œ/ํ™˜๊ฒฝ๋ณ€์ˆ˜์— ๋„ฃ์ง€ ์•Š์•„๋„ + * AWS SDK๊ฐ€ ๊ธฐ๋ณธ ์ž๊ฒฉ์ฆ๋ช… ์ฒด์ธ์œผ๋กœ ์ž„์‹œ ์ž๊ฒฉ์ฆ๋ช…์„ ์ž๋™ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. + * + * ํ•„์š”ํ•œ ํ™˜๊ฒฝ๋ณ€์ˆ˜(.env): + * - SQS_QUEUE_URL: ๋ฐœํ–‰ ๋Œ€์ƒ SQS Queue URL + * - LOG_LEVEL + */ +import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs"; +import pino from "pino"; + +const log = pino({ level: process.env.LOG_LEVEL || "info" }); + +// AWS SDK v3 SQS ํด๋ผ์ด์–ธํŠธ +// - credentials๋Š” ์ง€์ •ํ•˜์ง€ ์•Š์œผ๋ฉด Default Credential Provider Chain์„ ํ†ตํ•ด +// EC2 Role/ํ™˜๊ฒฝ๋ณ€์ˆ˜/๋กœ์ปฌ ์„ค์ • ๋“ฑ์„ ์ˆœ์„œ๋Œ€๋กœ ํƒ์ƒ‰ํ•ด ์ž๊ฒฉ์ฆ๋ช…์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. +const sqs = new SQSClient({ region: "ap-northeast-2" }); + +// ๋ฉ”์‹œ์ง€๋ฅผ ๋ฐœํ–‰ํ•  SQS Queue URL +const QUEUE_URL = process.env.SQS_QUEUE_URL; + +// ๋‹จ์ˆœ ๋Œ€๊ธฐ ์œ ํ‹ธ (์žฌ์‹œ๋„ ๊ฐ„๊ฒฉ ์ ์šฉ) +function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +// attempt๋ณ„ ๋Œ€๊ธฐ ์‹œ๊ฐ„ ๊ณ„์‚ฐ ๋ฐ˜ํ™˜ +// - attempt๊ฐ€ ์ฆ๊ฐ€ํ• ์ˆ˜๋ก ๋Œ€๊ธฐ ์‹œ๊ฐ„์„ ์ฆ๊ฐ€ + ์ž‘์€ ๋‚œ์ˆ˜๋ฅผ ๋”ํ•ด ๋™์‹œ ์žฌ์‹œ๋„(Thundering herd)๋ฅผ ์™„ํ™” +function backoffMs(attempt) { + const base = 200 * Math.pow(2, attempt); + const jitter = Math.floor(Math.random() * 100); + return Math.min(base + jitter, 3000); +} + +// ์—ญํ•  ๋ณ€๊ฒฝ ์ด๋ฒคํŠธ๋ฅผ SQS๋กœ ๋ฐœํ–‰ +// - payload๋Š” JSON์œผ๋กœ ์ง๋ ฌํ™”๋˜์–ด MessageBody๋กœ ๋“ค์–ด๊ฐ +// - ์‹คํŒจ ์‹œ ์ตœ๋Œ€ maxAttempts๊นŒ์ง€ ์žฌ์‹œ๋„ +export async function publishRoleChangeEvent(payload) { + if (!QUEUE_URL) throw new Error("SQS_QUEUE_URL is not set"); + + const body = JSON.stringify(payload); + + // ์žฌ์‹œ๋„ ์ •์ฑ… (์šด์˜/ํŠธ๋ž˜ํ”ฝ์— ๋งž์ถฐ ์กฐ์ • ๊ฐ€๋Šฅ) + const maxAttempts = 5; // ์ตœ๋Œ€ ์žฌ์‹œ๋„ ํšŸ์ˆ˜ + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + // SQS ๋ฉ”์‹œ์ง€ ๋ฐœํ–‰ ์š”์ฒญ + // ํ•„์š”ํ•˜๋ฉด MessageAttributes๋กœ eventType, schemaVersion ๋“ฑ์„ ์ถ”๊ฐ€ํ•ด + // ์†Œ๋น„์ž ์ธก ํ•„ํ„ฐ๋ง/๊ด€์ธก์— ํ™œ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + const cmd = new SendMessageCommand({ + QueueUrl: QUEUE_URL, + MessageBody: body, + // ํ•„์š”ํ•˜๋ฉด MessageAttributes๋กœ eventType ๋“ฑ ๋„ฃ์–ด๋„ ๋จ + }); + + const res = await sqs.send(cmd); + log.info({ eventId: payload.eventId, messageId: res.MessageId }, "SQS SendMessage OK"); + return res; + } catch (err) { + // ์ผ์‹œ์  ๋„คํŠธ์›Œํฌ ์˜ค๋ฅ˜/์Šค๋กœํ‹€๋ง ๋“ฑ์€ ์žฌ์‹œ๋„ํ•˜๋ฉด ํšŒ๋ณต๋˜๋Š” ๊ฒฝ์šฐ๊ฐ€ ๋งŽ์Šต๋‹ˆ๋‹ค. + // ์ตœ์ข… ์‹คํŒจ ์‹œ์—๋Š” throwํ•˜์—ฌ ์ƒ์œ„์—์„œ ์—๋Ÿฌ ๋กœ๊ทธ๋ฅผ ๋‚จ๊ธฐ๊ณ (ํ•„์š”์‹œ) ์•Œ๋ฆผ/๋Œ€์ฒด ๊ฒฝ๋กœ๋ฅผ ๊ณ ๋ คํ•ฉ๋‹ˆ๋‹ค. + const wait = backoffMs(attempt); // attempt๋ณ„ ๋Œ€๊ธฐ ์‹œ๊ฐ„ ๊ณ„์‚ฐ + log.warn( + { err, attempt: attempt + 1, maxAttempts, waitMs: wait, eventId: payload.eventId }, + "SQS SendMessage failed, retrying" + ); + if (attempt === maxAttempts - 1) throw err; + await sleep(wait); + } + } +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 58e7005..fd90d29 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,7 @@ services: green: container_name: green - image : konkukkuit/kuitee + image: konkukkuit/kuitee env_file: - .env ports: @@ -19,6 +19,18 @@ services: networks: - monitoring + discord-bot: + container_name: discord-bot + image: konkukkuit/kuitee-bot + env_file: + - .env + environment: + AWS_REGION: ap-northeast-2 + LOG_LEVEL: info + restart: unless-stopped + networks: + - monitoring + networks: monitoring: external: true # ์ด๋ฏธ docker network create monitoring ์œผ๋กœ ๋งŒ๋“ค์–ด ๋‘˜ ๊ฑฐ๋ผ external ๋กœ ์„ ์–ธ \ No newline at end of file diff --git a/scripts/deploy.sh b/scripts/deploy.sh index f0dd4f5..e297ac8 100644 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -1,15 +1,41 @@ #!/bin/bash +cd /home/ubuntu + +# --- discord-bot (always-on) update strategy --- # +# Bot์€ Gateway ์ด๋ฒคํŠธ๋ฅผ ์‹ค์‹œ๊ฐ„์œผ๋กœ ๋ฐ›๊ธฐ ๋•Œ๋ฌธ์—, ๋ฐฐํฌ ๋•Œ๋งˆ๋‹ค ๋ถˆํ•„์š”ํ•˜๊ฒŒ ์žฌ์‹œ์ž‘ํ•˜์ง€ ์•Š๋Š” ๊ฒƒ์ด ์ •ํ•ฉ์„ฑ/์•ˆ์ •์„ฑ์— ์œ ๋ฆฌํ•ฉ๋‹ˆ๋‹ค. +# bot ์ฝ”๋“œ๊ฐ€ ๋ณ€๊ฒฝ๋˜์–ด ์ƒˆ๋กœ์šด ์ด๋ฏธ์ง€๊ฐ€ pull ๋œ ๊ฒฝ์šฐ์—๋งŒ ์ปจํ…Œ์ด๋„ˆ๋ฅผ ์žฌ์ƒ์„ฑ(up -d) +echo "### discord-bot update check ###" + +# ํ˜„์žฌ ์‹คํ–‰ ์ค‘์ธ discord-bot ์ปจํ…Œ์ด๋„ˆ๊ฐ€ ์žˆ๋‹ค๋ฉด, ์ปจํ…Œ์ด๋„ˆ๊ฐ€ ์‚ฌ์šฉํ•˜๋Š” ์ด๋ฏธ์ง€ ID(sha)๋ฅผ ์ €์žฅ +CURRENT_BOT_IMAGE_ID="" +if docker ps --format '{{.Names}}' | grep -q '^discord-bot$'; then + CURRENT_BOT_IMAGE_ID=$(docker inspect -f '{{.Image}}' discord-bot 2>/dev/null || true) +fi + +docker compose -f "$COMPOSE_FILE" pull discord-bot >/dev/null 2>&1 || true +NEW_BOT_IMAGE_ID=$(docker compose -f "$COMPOSE_FILE" images -q discord-bot 2>/dev/null | head -n 1) + +if [ -z "$CURRENT_BOT_IMAGE_ID" ]; then + echo "discord-bot is not running. starting..." + docker compose -f "$COMPOSE_FILE" up -d discord-bot +elif [ -n "$NEW_BOT_IMAGE_ID" ] && [ "$CURRENT_BOT_IMAGE_ID" != "$NEW_BOT_IMAGE_ID" ]; then + echo "discord-bot image changed. recreating..." + docker compose -f "$COMPOSE_FILE" up -d --no-deps discord-bot +else + echo "discord-bot unchanged. skip restart." +fi + + # Ensure monitoring network exists (for Prometheus/Grafana/blue-green app communication) if ! docker network ls --format '{{.Name}}' | grep -q '^monitoring$'; then echo "0. create monitoring network" docker network create monitoring fi - IS_GREEN=$(docker ps | grep green) -if [ -z "$IS_GREEN" ];then # green๋ผ๋ฉด +if [ -z "$IS_GREEN" ];then # green๋ผ๋ฉด echo "### BLUE => GREEN ###" @@ -70,4 +96,4 @@ else fi echo "6. prune unused docker images" -sudo docker image prune -f \ No newline at end of file +sudo docker image prune -a -f \ No newline at end of file diff --git a/src/main/java/com/kuit/kupage/common/config/S3Config.java b/src/main/java/com/kuit/kupage/common/config/S3Config.java index 2bdf27b..1695df5 100644 --- a/src/main/java/com/kuit/kupage/common/config/S3Config.java +++ b/src/main/java/com/kuit/kupage/common/config/S3Config.java @@ -1,30 +1,45 @@ package com.kuit.kupage.common.config; -import com.amazonaws.auth.AWSStaticCredentialsProvider; -import com.amazonaws.auth.BasicAWSCredentials; -import com.amazonaws.services.s3.AmazonS3Client; -import com.amazonaws.services.s3.AmazonS3ClientBuilder; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; @Configuration public class S3Config { - @Value("${cloud.aws.credentials.access-key}") - private String accessKey; - @Value("${cloud.aws.credentials.secret-key}") - private String secretKey; - @Value("${cloud.aws.region.static}") + + /** + * awspring 3.x ์„ค์ • ํ‚ค๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. + * - application.yml: spring.cloud.aws.region.static: ap-northeast-2 + */ + @Value("${spring.cloud.aws.region.static:ap-northeast-2}") private String region; + /** + * S3Client (AWS SDK v2) + * - credentials๋Š” ์ง€์ •ํ•˜์ง€ ์•Š์œผ๋ฉด DefaultCredentialsProviderChain(EC2 Role/ํ™˜๊ฒฝ๋ณ€์ˆ˜/๋กœ์ปฌ ์„ค์ • ๋“ฑ)์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. + * - awspring starter๊ฐ€ ์ด๋ฏธ S3Client ๋นˆ์„ ๋งŒ๋“ค ์ˆ˜๋„ ์žˆ์œผ๋ฏ€๋กœ, ์ค‘๋ณต ์ƒ์„ฑ์„ ํ”ผํ•˜๊ธฐ ์œ„ํ•ด @ConditionalOnMissingBean์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. + */ @Bean - public AmazonS3Client amazonS3Client() { - BasicAWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey); + @ConditionalOnMissingBean + public S3Client s3Client() { + return S3Client.builder() + .region(Region.of(region)) + .build(); + } - return (AmazonS3Client) AmazonS3ClientBuilder - .standard() - .withCredentials(new AWSStaticCredentialsProvider(credentials)) - .withRegion(region) + /** + * Presigned URL ์ƒ์„ฑ์— ์‚ฌ์šฉํ•˜๋Š” Presigner (AWS SDK v2) + * - ํ•„์š” ์‹œ ์„œ๋น„์Šค์—์„œ ์ฃผ์ž… ๋ฐ›์•„ presignPutObject/presignGetObject ๋“ฑ์— ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. + */ + @Bean + @ConditionalOnMissingBean + public S3Presigner s3Presigner() { + return S3Presigner.builder() + .region(Region.of(region)) .build(); } } diff --git a/src/main/java/com/kuit/kupage/common/config/SecurityConfig.java b/src/main/java/com/kuit/kupage/common/config/SecurityConfig.java index 0332225..7552753 100644 --- a/src/main/java/com/kuit/kupage/common/config/SecurityConfig.java +++ b/src/main/java/com/kuit/kupage/common/config/SecurityConfig.java @@ -84,6 +84,7 @@ public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); config.setAllowedOrigins(List.of( "http://localhost:5173", + "http://localhost:9000", "https://konkuk-kuit.github.io" )); config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); diff --git a/src/main/java/com/kuit/kupage/common/file/PresignedUrlController.java b/src/main/java/com/kuit/kupage/common/file/PresignedUrlController.java index e196e63..c51cef4 100644 --- a/src/main/java/com/kuit/kupage/common/file/PresignedUrlController.java +++ b/src/main/java/com/kuit/kupage/common/file/PresignedUrlController.java @@ -6,7 +6,6 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; @@ -25,6 +24,7 @@ public class PresignedUrlController { private final static Integer MAX_IMAGE_SIZE = 20 * 1024 * 1024; // 20MB private final static Integer MAX_FILE_SIZE = 120 * 1024 * 1024; // 120MB + @PostMapping("/pre-signed/articles/file") @Operation(summary = "๊ฒŒ์‹œ๊ธ€ ํŒŒ์ผ ์—…๋กœ๋“œ ๋งํฌ ์ œ๊ณต API", description = "๋กœ๊ทธ์ธ ํ•œ ์œ ์ €๊ฐ€ ๊ฒŒ์‹œ๊ธ€์„ ์œ„ํ•œ ํŒŒ์ผ ์—…๋กœ๋“œ ๋งํฌ๋ฅผ ์ œ๊ณต๋ฐ›์Šต๋‹ˆ๋‹ค.") public BaseResponse getFilePresignedUrl(@RequestBody PresignedUrlRequest request) { diff --git a/src/main/java/com/kuit/kupage/common/file/PresignedUrlService.java b/src/main/java/com/kuit/kupage/common/file/PresignedUrlService.java index d868629..9016012 100644 --- a/src/main/java/com/kuit/kupage/common/file/PresignedUrlService.java +++ b/src/main/java/com/kuit/kupage/common/file/PresignedUrlService.java @@ -1,82 +1,51 @@ package com.kuit.kupage.common.file; -import com.amazonaws.HttpMethod; -import com.amazonaws.services.s3.AmazonS3; -import com.amazonaws.services.s3.Headers; -import com.amazonaws.services.s3.model.CannedAccessControlList; -import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest; +import java.time.Duration; +import java.util.UUID; import lombok.RequiredArgsConstructor; -import org.apache.http.protocol.HTTP; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; - -import java.net.URL; -import java.util.Date; -import java.util.UUID; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; @Service @RequiredArgsConstructor public class PresignedUrlService { + @Value("${cloud.aws.s3.bucket-name}") private String bucket; - private final AmazonS3 amazonS3; + private final S3Presigner s3Presigner; - /** - * presigned url ๋ฐœ๊ธ‰ - * - * @param prefix ๋ฒ„ํ‚ท ๋””๋ ‰ํ† ๋ฆฌ ์ด๋ฆ„ - * @param fileName ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์ „๋‹ฌํ•œ ํŒŒ์ผ๋ช… ํŒŒ๋ผ๋ฏธํ„ฐ - * @return presigned url - */ public String getPreSignedUrl(String prefix, String contentType, String contentLength, String fileName) { if (StringUtils.hasText(prefix)) { fileName = createPath(prefix, fileName); } - GeneratePresignedUrlRequest generatePresignedUrlRequest = getGeneratePreSignedUrlRequest(bucket, contentType, contentLength, fileName); - URL url = amazonS3.generatePresignedUrl(generatePresignedUrlRequest); - return url.toString(); - } + PutObjectRequest putObjectRequest = buildPutObjectRequest(bucket, fileName, contentType); - /** - * ํŒŒ์ผ ์—…๋กœ๋“œ์šฉ(PUT) presigned url ์ƒ์„ฑ - * - * @param bucket ๋ฒ„ํ‚ท ์ด๋ฆ„ - * @param fileName S3 ์—…๋กœ๋“œ์šฉ ํŒŒ์ผ ์ด๋ฆ„ - * @return presigned url - */ - private GeneratePresignedUrlRequest getGeneratePreSignedUrlRequest(String bucket, String contentType, String contentLength, String fileName) { - GeneratePresignedUrlRequest generatePresignedUrlRequest = - new GeneratePresignedUrlRequest(bucket, fileName) - .withMethod(HttpMethod.PUT) - .withExpiration(getPreSignedUrlExpiration()); - generatePresignedUrlRequest.addRequestParameter( - Headers.S3_CANNED_ACL, - CannedAccessControlList.PublicRead.toString()); - generatePresignedUrlRequest.putCustomRequestHeader(HTTP.CONTENT_TYPE, contentType); - generatePresignedUrlRequest.putCustomRequestHeader(HTTP.CONTENT_LEN, contentLength); - return generatePresignedUrlRequest; + PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(2)) // ์œ ํšจ๊ธฐ๊ฐ„: 2๋ถ„ + .putObjectRequest(putObjectRequest) + .build(); + + return s3Presigner.presignPutObject(presignRequest).url().toString(); } - /** - * presigned url ์œ ํšจ ๊ธฐ๊ฐ„ ์„ค์ • - * - * @return ์œ ํšจ๊ธฐ๊ฐ„ - */ - private Date getPreSignedUrlExpiration() { - Date expiration = new Date(); - long expTimeMillis = expiration.getTime(); - expTimeMillis += 1000 * 60 * 2; - expiration.setTime(expTimeMillis); - return expiration; + private PutObjectRequest buildPutObjectRequest(String bucket, String key, String contentType) { + PutObjectRequest.Builder builder = PutObjectRequest.builder() + .bucket(bucket) + .key(key); + + if (StringUtils.hasText(contentType)) { + builder.contentType(contentType); + } + return builder.build(); } /** * ํŒŒ์ผ์˜ ์ „์ฒด ๊ฒฝ๋กœ๋ฅผ ์ƒ์„ฑ - * - * @param prefix ๋””๋ ‰ํ† ๋ฆฌ ๊ฒฝ๋กœ - * @return ํŒŒ์ผ์˜ ์ „์ฒด ๊ฒฝ๋กœ */ private String createPath(String prefix, String fileName) { String fileId = createFileId(); @@ -85,8 +54,6 @@ private String createPath(String prefix, String fileName) { /** * ํŒŒ์ผ ๊ณ ์œ  ID๋ฅผ ์ƒ์„ฑ - * - * @return 36์ž๋ฆฌ์˜ UUID */ private String createFileId() { return UUID.randomUUID().toString(); diff --git a/src/main/java/com/kuit/kupage/common/file/S3Service.java b/src/main/java/com/kuit/kupage/common/file/S3Service.java index 7b2904b..7d82f3f 100644 --- a/src/main/java/com/kuit/kupage/common/file/S3Service.java +++ b/src/main/java/com/kuit/kupage/common/file/S3Service.java @@ -1,15 +1,16 @@ package com.kuit.kupage.common.file; -import com.amazonaws.services.s3.AmazonS3Client; -import com.amazonaws.services.s3.model.CannedAccessControlList; -import com.amazonaws.services.s3.model.ObjectMetadata; -import com.amazonaws.services.s3.model.PutObjectRequest; import com.kuit.kupage.exception.KupageException; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.ObjectCannedACL; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import java.io.InputStream; import java.util.Objects; import java.util.UUID; @@ -19,7 +20,7 @@ @RequiredArgsConstructor public class S3Service { - private final AmazonS3Client s3Client; + private final S3Client s3Client; @Value("${cloud.aws.s3.bucket-name}") private String bucketName; @Value("${cloud.aws.cloudfront.deploy-url}") @@ -40,14 +41,19 @@ public String uploadFile(MultipartFile file) { } private String uploadToS3(MultipartFile file, String s3FileName) { - ObjectMetadata objectMetadata = new ObjectMetadata(); - objectMetadata.setContentType(file.getContentType()); - objectMetadata.setContentLength(file.getSize()); - - try { - PutObjectRequest s3Object = new PutObjectRequest(bucketName, s3FileName, file.getInputStream(), objectMetadata) - .withCannedAcl(CannedAccessControlList.PublicRead); - s3Client.putObject(s3Object); + try (InputStream in = file.getInputStream()) { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucketName) + .key(s3FileName) + .contentType(file.getContentType()) + // Public Access Block ๋น„ํ™œ์„ฑํ™” ์ „์ œ: ์—…๋กœ๋“œ ๊ฐ์ฒด๋ฅผ public-read๋กœ ์„ค์ • + .acl(ObjectCannedACL.PUBLIC_READ) + .build(); + + s3Client.putObject( + putObjectRequest, + RequestBody.fromInputStream(in, file.getSize()) + ); } catch (Exception e) { throw new KupageException(AWS_S3_UPLOAD_ISSUE); } diff --git a/src/main/java/com/kuit/kupage/common/response/ResponseCode.java b/src/main/java/com/kuit/kupage/common/response/ResponseCode.java index 1d4c389..654a586 100644 --- a/src/main/java/com/kuit/kupage/common/response/ResponseCode.java +++ b/src/main/java/com/kuit/kupage/common/response/ResponseCode.java @@ -80,7 +80,7 @@ public enum ResponseCode { NONE_ARTICLE(false, 4012, "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์•„ํ‹ฐํด์ž…๋‹ˆ๋‹ค."), - // 5000 : project ๊ด€๋ จ + // 5000 ๋ฒˆ๋Œ€ : project ๊ด€๋ จ NONE_PROJECT(false, 5000, "์กด์žฌํ•˜์ง€ ์•Š๋Š” ํ”„๋กœ์ ํŠธ์ž…๋‹ˆ๋‹ค."), @@ -97,7 +97,10 @@ public enum ResponseCode { ALREADY_COMPLETED_TEAM_MATCH(false, 6009, "์ด๋ฏธ ์™„๋ฃŒ๋œ ํŒ€๋งค์นญ์ž…๋‹ˆ๋‹ค."), PM_PROJECT_LIMIT_EXCEEDED(false, 6010, "PM ๋ถ€์›์€ ํ•œ ๊ธฐ์ˆ˜์— 1๊ฐœ์˜ ํ”„๋กœ์ ํŠธ๋งŒ ๋“ฑ๋กํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."), FINAL_RESULT(true, 6011, "ํŒ€๋งค์นญ ์ตœ์ข… ๊ฒฐ๊ณผ์ž…๋‹ˆ๋‹ค."), - INVALID_APPLY_PART(false, 6012, "ํ˜„์žฌ ๊ธฐ์ˆ˜์—์„œ๋Š” ์ž์‹ ์˜ ํŒŒํŠธ์™€ ์ผ์น˜ํ•˜๋Š” ํŒŒํŠธ์—๋งŒ ์ง€์›ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."); + INVALID_APPLY_PART(false, 6012, "ํ˜„์žฌ ๊ธฐ์ˆ˜์—์„œ๋Š” ์ž์‹ ์˜ ํŒŒํŠธ์™€ ์ผ์น˜ํ•˜๋Š” ํŒŒํŠธ์—๋งŒ ์ง€์›ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."), + + // 7000 ๋ฒˆ๋Œ€ : AWS(S3, SQS) ์ธํ”„๋ผ ๊ด€๋ จ ์˜ค๋ฅ˜ + SQS_MESSAGE_HANDLE_FAIL(false, 7000, "SQS ๋ฉ”์‹œ์ง€ ์ฒ˜๋ฆฌ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."); private boolean isSuccess; diff --git a/src/main/java/com/kuit/kupage/domain/role/service/RoleService.java b/src/main/java/com/kuit/kupage/domain/role/service/RoleService.java index 4155617..b2433a0 100644 --- a/src/main/java/com/kuit/kupage/domain/role/service/RoleService.java +++ b/src/main/java/com/kuit/kupage/domain/role/service/RoleService.java @@ -6,6 +6,7 @@ import com.kuit.kupage.domain.role.dto.DiscordMemberResponse; import com.kuit.kupage.domain.role.dto.DiscordRoleResponse; import com.kuit.kupage.domain.role.repository.RoleRepository; +import com.kuit.kupage.infra.dto.DiscordRoleChangeEvent; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -120,7 +121,87 @@ public int batchInsertRoleMember(List discordMemberRespon return memberRoleRepository.saveAll(newMemberRoles).size(); } + public void applyDiscordRoleChangeEvent(DiscordRoleChangeEvent event) { + final String memberDiscordId = event.discordUserId(); + if (memberDiscordId == null || memberDiscordId.isBlank()) { + log.warn("[์—ญํ• ๋ณ€๊ฒฝ๋ฐ˜์˜] discordUserId๊ฐ€ ๋น„์–ด ์žˆ์–ด ์ฒ˜๋ฆฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. eventId={}", event.eventId()); + return; + } + + final List addedRoleIds = event.addedRoleIds() == null ? List.of() : event.addedRoleIds(); + final List removedRoleIds = event.removedRoleIds() == null ? List.of() : event.removedRoleIds(); + + if (addedRoleIds.isEmpty() && removedRoleIds.isEmpty()) { + log.info("[์—ญํ• ๋ณ€๊ฒฝ๋ฐ˜์˜] ์ถ”๊ฐ€/์‚ญ์ œ ์—ญํ• ์ด ์—†์–ด ์ฒ˜๋ฆฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. eventId={}, discordUserId={}", event.eventId(), memberDiscordId); + return; + } + + // 1. ์ด๋ฒˆ ์ด๋ฒคํŠธ์— ํฌํ•จ๋œ roleId๋“ค์„ ์กฐํšŒํ•ด์„œ Map ์ƒ์„ฑ + Set targetDiscordRoleIds = new HashSet<>(); + targetDiscordRoleIds.addAll(addedRoleIds); + targetDiscordRoleIds.addAll(removedRoleIds); + + Map rolesByDiscordId = roleRepository.findAllByDiscordRoleId(new ArrayList<>(targetDiscordRoleIds)).stream() + .filter(r -> r.getDiscordRoleId() != null) + .collect(Collectors.toMap(Role::getDiscordRoleId, Function.identity(), (a, b) -> a)); + + // 2. ํ˜„์žฌ ์‚ฌ์šฉ์ž(memberDiscordId)์˜ ๊ธฐ์กด MemberRole ๋ชฉ๋ก ์กฐํšŒ + List existingForMember = memberRoleRepository.findByMemberDiscordId(memberDiscordId).stream() + .filter(mr -> memberDiscordId.equals(mr.getMemberDiscordId())) + .toList(); + + Set existingRoleDiscordIds = existingForMember.stream() + .map(mr -> mr.getRole() == null ? null : mr.getRole().getDiscordRoleId()) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + // 3. ์ถ”๊ฐ€ ์—ญํ•  ๋ฐ˜์˜: ์—†์œผ๋ฉด insert + List toInsert = new ArrayList<>(); + for (String roleDiscordId : addedRoleIds) { + Role role = rolesByDiscordId.get(roleDiscordId); + if (role == null) { + log.warn("[์—ญํ• ๋ณ€๊ฒฝ๋ฐ˜์˜] DB์— ์—†๋Š” ๋””์Šค์ฝ”๋“œ ์—ญํ• ์ด๋ผ ์ถ”๊ฐ€๋ฅผ ๊ฑด๋„ˆ๋œ๋‹ˆ๋‹ค. discordRoleId={}, eventId={}", roleDiscordId, event.eventId()); + continue; + } + if (existingRoleDiscordIds.contains(roleDiscordId)) { + log.debug("[์—ญํ• ๋ณ€๊ฒฝ๋ฐ˜์˜] ์ด๋ฏธ ๋ถ€์—ฌ๋œ ์—ญํ• ์ž…๋‹ˆ๋‹ค. discordUserId={}, discordRoleId={}, roleName={}", + memberDiscordId, roleDiscordId, role.getName()); + continue; + } + toInsert.add(new MemberRole(memberDiscordId, role)); + } + + if (!toInsert.isEmpty()) { + try { + memberRoleRepository.saveAll(toInsert); + log.info("[์—ญํ• ๋ณ€๊ฒฝ๋ฐ˜์˜] ์—ญํ•  {}๊ฐœ ์ถ”๊ฐ€ ์™„๋ฃŒ. eventId={}, discordUserId={}", + toInsert.size(), event.eventId(), memberDiscordId); + } catch (DataIntegrityViolationException e) { + // ๋™์‹œ์„ฑ/์ค‘๋ณต ์ €์žฅ ์‹œ๋„ ๋“ฑ์œผ๋กœ ์ œ์•ฝ์กฐ๊ฑด ์œ„๋ฐ˜์ด ๋‚  ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ ๋กœ๊ทธ๋งŒ ๋‚จ๊ธฐ๊ณ  ์ง„ํ–‰ + log.warn("[์—ญํ• ๋ณ€๊ฒฝ๋ฐ˜์˜] ์—ญํ•  ์ถ”๊ฐ€ ์ค‘ ์ œ์•ฝ์กฐ๊ฑด ์œ„๋ฐ˜์ด ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค. eventId={}, discordUserId={}, message={}", + event.eventId(), memberDiscordId, e.getMessage()); + } + } + + // 4. ์ œ๊ฑฐ ์—ญํ•  ๋ฐ˜์˜: ์žˆ์œผ๋ฉด delete + Set removedSet = new HashSet<>(removedRoleIds); + List toDelete = existingForMember.stream() + .filter(mr -> mr.getRole() != null && mr.getRole().getDiscordRoleId() != null) + .filter(mr -> removedSet.contains(mr.getRole().getDiscordRoleId())) + .toList(); + + if (!toDelete.isEmpty()) { + memberRoleRepository.deleteAll(toDelete); + log.info("[์—ญํ• ๋ณ€๊ฒฝ๋ฐ˜์˜] ์—ญํ•  {}๊ฐœ ์ œ๊ฑฐ ์™„๋ฃŒ. eventId={}, discordUserId={}", + toDelete.size(), event.eventId(), memberDiscordId); + } + + log.info("[์—ญํ• ๋ณ€๊ฒฝ๋ฐ˜์˜] ์ฒ˜๋ฆฌ ์™„๋ฃŒ. eventId={}, discordUserId={}, ์ถ”๊ฐ€={}, ์ œ๊ฑฐ={}", + event.eventId(), memberDiscordId, addedRoleIds.size(), removedRoleIds.size()); + } + private String createKey(String memberDiscordId, Long roleId) { return "(" + memberDiscordId + "," + roleId + ")"; } + } diff --git a/src/main/java/com/kuit/kupage/infra/config/SqsConfig.java b/src/main/java/com/kuit/kupage/infra/config/SqsConfig.java new file mode 100644 index 0000000..e8ddd57 --- /dev/null +++ b/src/main/java/com/kuit/kupage/infra/config/SqsConfig.java @@ -0,0 +1,23 @@ +package com.kuit.kupage.infra.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.sqs.SqsClient; + +@Configuration +public class SqsConfig { + + @Value("${spring.cloud.aws.region.static}") + private String region; + + @Bean + @ConditionalOnMissingBean(SqsClient.class) + public SqsClient sqsClient() { + return SqsClient.builder() + .region(Region.of(region)) + .build(); + } +} diff --git a/src/main/java/com/kuit/kupage/infra/domain/ProcessedEventLog.java b/src/main/java/com/kuit/kupage/infra/domain/ProcessedEventLog.java new file mode 100644 index 0000000..e826ae1 --- /dev/null +++ b/src/main/java/com/kuit/kupage/infra/domain/ProcessedEventLog.java @@ -0,0 +1,45 @@ +package com.kuit.kupage.infra.domain; + +import com.kuit.kupage.common.type.BaseEntity; +import com.kuit.kupage.infra.dto.DiscordRoleChangeEvent; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table( + name = "processed_events", + uniqueConstraints = @UniqueConstraint(name = "uk_processed_events_event_id", columnNames = "event_id") +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class ProcessedEventLog extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 64) + private String eventId; + + private String discordUserId; + + private String discordLoginId; + + private String eventType; + + private LocalDateTime occurredAt; + + public static ProcessedEventLog from(DiscordRoleChangeEvent event) { + ProcessedEventLog processedEventLog = new ProcessedEventLog(); + processedEventLog.eventId = event.eventId(); + processedEventLog.discordUserId = event.discordUserId(); + processedEventLog.discordLoginId = event.discordLoginId(); + processedEventLog.eventType = event.eventType(); + processedEventLog.occurredAt = event.occurredAt(); + return processedEventLog; + } +} \ No newline at end of file diff --git a/src/main/java/com/kuit/kupage/infra/dto/DiscordRoleChangeEvent.java b/src/main/java/com/kuit/kupage/infra/dto/DiscordRoleChangeEvent.java new file mode 100644 index 0000000..db9eb87 --- /dev/null +++ b/src/main/java/com/kuit/kupage/infra/dto/DiscordRoleChangeEvent.java @@ -0,0 +1,108 @@ +package com.kuit.kupage.infra.dto; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.List; + +public record DiscordRoleChangeEvent( + String eventType, + String eventId, + LocalDateTime occurredAt, // ISO-8601 (์˜ˆ: 2025-12-30T13:00:00.000Z) + String guildId, + String discordUserId, + String discordLoginId, + List addedRoleIds, // ์ถ”๊ฐ€๋œ ์—ญํ•  ID ๋ชฉ๋ก + List removedRoleIds, // ์ œ๊ฑฐ๋œ ์—ญํ•  ID ๋ชฉ๋ก + String source, + int schemaVersion +) { + + /** + * Z/offset์ด ํฌํ•จ๋œ ISO-8601(ex. 2025-12-30T13:00:00.000Z, +09:00)๋„ ํ—ˆ์šฉํ•˜๋„๋ก + * OffsetDateTime์œผ๋กœ ํŒŒ์‹ฑ ํ›„ LocalDateTime์œผ๋กœ ๋ณ€ํ™˜ + */ + public static DiscordRoleChangeEvent fromJson(String body, ObjectMapper objectMapper) throws Exception { + JsonNode root = objectMapper.readTree(body); + return fromJson(root); + } + + /** + * ์ด๋ฏธ ํŒŒ์‹ฑ๋œ JsonNode์—์„œ ์ด๋ฒคํŠธ๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. + */ + public static DiscordRoleChangeEvent fromJson(JsonNode root) { + String eventType = textOrNull(root, "eventType"); + String eventId = textOrNull(root, "eventId"); + LocalDateTime occurredAt = parseOccurredAt(textOrNull(root, "occurredAt")); + String guildId = textOrNull(root, "guildId"); + String discordUserId = textOrNull(root, "discordUserId"); + String discordLoginId = textOrNull(root, "discordLoginId"); + List addedRoleIds = stringListOrEmpty(root, "addedRoleIds"); + List removedRoleIds = stringListOrEmpty(root, "removedRoleIds"); + String source = textOrNull(root, "source"); + int schemaVersion = intOrDefault(root, "schemaVersion", 1); + + return new DiscordRoleChangeEvent( + eventType, + eventId, + occurredAt, + guildId, + discordUserId, + discordLoginId, + addedRoleIds, + removedRoleIds, + source, + schemaVersion + ); + } + + private static LocalDateTime parseOccurredAt(String occurredAt) { + if (occurredAt == null || occurredAt.isBlank()) return null; + + try { + // 1) LocalDateTime ํ˜•์‹ ์‹œ๋„ (์˜ˆ: 2025-12-30T13:00:00) + return LocalDateTime.parse(occurredAt); + } catch (DateTimeParseException ignore) { + // 2) Z/offset ํฌํ•จ ํ˜•์‹ ์‹œ๋„ (์˜ˆ: 2025-12-30T13:00:00.000Z / 2025-12-30T22:00:00+09:00) + try { + return OffsetDateTime.parse(occurredAt).toLocalDateTime(); + } catch (DateTimeParseException e) { + return null; + } + } + } + + private static String textOrNull(JsonNode root, String field) { + JsonNode node = root.get(field); + if (node == null || node.isNull()) return null; + String v = node.asText(); + return (v == null || v.isBlank()) ? null : v; + } + + private static int intOrDefault(JsonNode root, String field, int defaultValue) { + JsonNode node = root.get(field); + if (node == null || node.isNull()) return defaultValue; + if (node.isInt()) return node.asInt(); + try { + return Integer.parseInt(node.asText()); + } catch (Exception e) { + return defaultValue; + } + } + + private static List stringListOrEmpty(JsonNode root, String field) { + JsonNode arr = root.get(field); + if (arr == null || !arr.isArray()) return List.of(); + + List out = new ArrayList<>(); + for (JsonNode n : arr) { + if (n == null || n.isNull()) continue; + String v = n.asText(); + if (v != null && !v.isBlank()) out.add(v); + } + return out; + } +} diff --git a/src/main/java/com/kuit/kupage/infra/handler/RoleChangeEventService.java b/src/main/java/com/kuit/kupage/infra/handler/RoleChangeEventService.java new file mode 100644 index 0000000..3ef3783 --- /dev/null +++ b/src/main/java/com/kuit/kupage/infra/handler/RoleChangeEventService.java @@ -0,0 +1,57 @@ +package com.kuit.kupage.infra.handler; + +import com.kuit.kupage.domain.role.service.RoleService; +import com.kuit.kupage.infra.domain.ProcessedEventLog; +import com.kuit.kupage.infra.dto.DiscordRoleChangeEvent; +import com.kuit.kupage.infra.repository.ProcessedEventRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * SQS๋กœ๋ถ€ํ„ฐ ์ˆ˜์‹ ํ•œ Event๋ฅผ ์ฒ˜๋ฆฌํ•˜๋Š” ์„œ๋น„์Šค + * 1. eventId ๊ธฐ๋ฐ˜ ๋ฉฑ๋“ฑ ์ฒ˜๋ฆฌ + * 2. ์šฐ๋ฆฌ ์„œ๋น„์Šค DB์— ์—ญํ•  ๋ฐ˜์˜ํ•˜๋„๋ก roleService์— ์œ„์ž„ + * 3. ์‹คํŒจ ์‹œ ์˜ˆ์™ธ๋ฅผ ๋˜์ ธ SQS ์žฌ์‹œ๋„/DLQ๋กœ ๊ฐ€๋„๋ก ์„ค๊ณ„ + */ +@Slf4j +@RequiredArgsConstructor +@Service +public class RoleChangeEventService { + + private final ProcessedEventRepository processedEventRepository; + private final RoleService roleService; + + @Transactional + public void process(DiscordRoleChangeEvent event) { + // 1. ๋ฉฑ๋“ฑ ์ฒ˜๋ฆฌ: eventId๋ฅผ ๊ธฐ์ค€์œผ๋กœ ์ด๋ฏธ ์ฒ˜๋ฆฌ๋œ ์ด๋ฒคํŠธ๋Š” ์ œ์™ธ + if (!tryMarkProcessed(event)) { + log.info("[SQS] ์ค‘๋ณต ์ด๋ฒคํŠธ๋ผ ์ฒ˜๋ฆฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. eventId={}", event.eventId()); + return; + } + + // 2. ์ฟ ์ดํ‹ฐ DB์— ๋””์Šค์ฝ”๋“œ ์—ญํ•  ๋ณ€๊ฒฝ ๋ฐ˜์˜ + log.info( + "[SQS] ๋””์Šค์ฝ”๋“œ ์—ญํ•  ๋ณ€๊ฒฝ ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ์‹œ์ž‘. eventId={}, guildId={}, discordUserId={}, added={}, removed={}, occurredAt={}", + event.eventId(), + event.guildId(), + event.discordUserId(), + event.addedRoleIds() == null ? 0 : event.addedRoleIds().size(), + event.removedRoleIds() == null ? 0 : event.removedRoleIds().size(), + event.occurredAt() + ); + roleService.applyDiscordRoleChangeEvent(event); + log.info("[SQS] ๋””์Šค์ฝ”๋“œ ์—ญํ•  ๋ณ€๊ฒฝ ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ์™„๋ฃŒ. eventId={}", event.eventId()); + } + + private boolean tryMarkProcessed(DiscordRoleChangeEvent event) { + try { + processedEventRepository.save(ProcessedEventLog.from(event)); + return true; + } catch (DataIntegrityViolationException e) { + return false; // ์ด๋ฏธ ์ฒ˜๋ฆฌ๋œ ์ด๋ฒคํŠธ + } + } +} diff --git a/src/main/java/com/kuit/kupage/infra/handler/RoleChangeListener.java b/src/main/java/com/kuit/kupage/infra/handler/RoleChangeListener.java new file mode 100644 index 0000000..3e05898 --- /dev/null +++ b/src/main/java/com/kuit/kupage/infra/handler/RoleChangeListener.java @@ -0,0 +1,64 @@ +package com.kuit.kupage.infra.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.kuit.kupage.exception.KupageException; +import com.kuit.kupage.infra.dto.DiscordRoleChangeEvent; +import io.awspring.cloud.sqs.annotation.SqsListener; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import static com.kuit.kupage.common.response.ResponseCode.SQS_MESSAGE_HANDLE_FAIL; + +@Slf4j +@Service +@Profile("!test") +public class RoleChangeListener { + + private final ObjectMapper objectMapper; + private final RoleChangeEventService roleChangeEventService; + + public RoleChangeListener( + ObjectMapper objectMapper, + RoleChangeEventService roleChangeEventService + ) { + this.objectMapper = objectMapper; + this.roleChangeEventService = roleChangeEventService; + } + + @SqsListener("${sqs.queue-name}") + public void handle(String body) { + try { + DiscordRoleChangeEvent event = DiscordRoleChangeEvent.fromJson(body, objectMapper); + + if (event.eventId() == null || event.eventId().isBlank()) { + log.warn("[SQS] eventId๊ฐ€ ๋น„์–ด์žˆ์–ด ๋ฉ”์‹œ์ง€๋ฅผ ๋ฌด์‹œํ•ฉ๋‹ˆ๋‹ค. body={}", body); + return; + } + + log.info( + "[SQS] ๋””์Šค์ฝ”๋“œ ์—ญํ•  ๋ณ€๊ฒฝ ์ด๋ฒคํŠธ ์ˆ˜์‹ . eventId={}, guildId={}, discordUserId={}, discordLoginId={}, added={}, removed={}, occurredAt={}", + event.eventId(), + event.guildId(), + event.discordUserId(), + event.discordLoginId(), + event.addedRoleIds() == null ? 0 : event.addedRoleIds().size(), + event.removedRoleIds() == null ? 0 : event.removedRoleIds().size(), + event.occurredAt() + ); + + roleChangeEventService.process(event); + + } catch (JsonProcessingException | IllegalArgumentException e) { + // ํŒŒ์‹ฑ/๊ฒ€์ฆ ์‹คํŒจ ๋ฉ”์‹œ์ง€ => ์žฌ์‹œ๋„ํ•ด๋„ ์„ฑ๊ณตํ•  ๊ฐ€๋Šฅ์„ฑ์ด ๋‚ฎ์Œ + log.error("[SQS] ์œ ํšจํ•˜์ง€ ์•Š์€ ๋ฉ”์‹œ์ง€๋ผ ์žฌ์‹œ๋„ ์—†์ด ํ๊ธฐ. body={}", body, e); + return; + } catch (Exception e) { + // ์ฒ˜๋ฆฌ ์‹คํŒจ(์ผ์‹œ ์žฅ์• /DB lock/๋„คํŠธ์›Œํฌ ๋“ฑ) => ์žฌ์‹œ๋„ํ•˜๋ฉด ์„ฑ๊ณต + // ๋ฉ”์‹œ์ง€๋Š” visibility timeout ํ›„ ์žฌ์ „๋‹ฌ, ์„ค์ •๋œ maxReceiveCount๋ฅผ ๋„˜์œผ๋ฉด DLQ๋กœ ์ด๋™ + log.error("[SQS] ๋ฉ”์‹œ์ง€ ์ฒ˜๋ฆฌ ์‹คํŒจ๋กœ ์žฌ์ „๋‹ฌ์„ ํ†ตํ•ด ์žฌ์‹œ๋„. body={}", body, e); + throw new KupageException(SQS_MESSAGE_HANDLE_FAIL); + } + } +} diff --git a/src/main/java/com/kuit/kupage/infra/repository/ProcessedEventRepository.java b/src/main/java/com/kuit/kupage/infra/repository/ProcessedEventRepository.java new file mode 100644 index 0000000..6b85404 --- /dev/null +++ b/src/main/java/com/kuit/kupage/infra/repository/ProcessedEventRepository.java @@ -0,0 +1,7 @@ +package com.kuit.kupage.infra.repository; + +import com.kuit.kupage.infra.domain.ProcessedEventLog; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProcessedEventRepository extends JpaRepository { +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 05f9696..8cb29f9 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -39,6 +39,24 @@ spring: max-file-size: 120MB max-request-size: 400MB + cloud: + aws: + credentials: + access-key: ${AWS_ACCESS_KEY_ID} + secret-key: ${AWS_SECRET_ACCESS_KEY} + region: + static: ap-northeast-2 + sqs: + listener: + poll-timeout: 20s # long polling (์ตœ๋Œ€ 20์ดˆ) + max-messages-per-poll: 10 # SQS 1ํšŒ poll ์ตœ๋Œ€ 10๊ฐœ + message-visibility: 60s # ์ฒ˜๋ฆฌ ์‹œ๊ฐ„์— ๋งž์ถฐ(์˜ต์…˜) + +sqs: + queue-url: ${SQS_QUEUE_URL} + queue-name: ${SQS_QUEUE_NAME} + + discord: guild-id: ${DISCORD_GUILD_ID} bot-token: ${DISCORD_BOT_TOKEN} @@ -49,14 +67,6 @@ cloud: bucket-name: ${AWS_S3_BUCKET_NAME} cloudfront: deploy-url: ${CLOUDFRONT_DEPLOY_URL} - credentials: - access-key: ${AWS_ACCESS_KEY_ID} - secret-key: ${AWS_SECRET_ACCESS_KEY} - region: - static: ap-northeast-2 - auto: false - stack: - auto: false secret: jwt: diff --git a/src/main/resources/db/migration/V2__create_processed_events.sql b/src/main/resources/db/migration/V2__create_processed_events.sql new file mode 100644 index 0000000..4acbfd5 --- /dev/null +++ b/src/main/resources/db/migration/V2__create_processed_events.sql @@ -0,0 +1,12 @@ +CREATE TABLE processed_events ( + id BIGINT NOT NULL AUTO_INCREMENT, + event_id VARCHAR(64) NOT NULL, + discord_user_id VARCHAR(32) NULL, + discord_login_id VARCHAR(64) NULL, + event_type VARCHAR(64) NULL, + occurred_at DATETIME(6) NULL, + created_at DATETIME(6) NOT NULL, + modified_at DATETIME(6) NOT NULL, + CONSTRAINT pk_processed_events PRIMARY KEY (id), + CONSTRAINT uk_processed_events_event_id UNIQUE (event_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; \ No newline at end of file diff --git a/src/test/java/com/kuit/kupage/global/db/FlywayMigrationTest.java b/src/test/java/com/kuit/kupage/global/db/FlywayMigrationTest.java deleted file mode 100644 index 9751876..0000000 --- a/src/test/java/com/kuit/kupage/global/db/FlywayMigrationTest.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.kuit.kupage.global.db; - -import org.assertj.core.api.Assertions; -import org.flywaydb.core.Flyway; -import org.flywaydb.core.api.MigrationInfoService; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; - -import javax.sql.DataSource; - -@SpringBootTest -@ActiveProfiles("test") -public class FlywayMigrationTest { - - @Autowired - private DataSource dataSource; - - @Test - void flyway_status() { - Flyway flyway = Flyway.configure() - .dataSource(dataSource) - .locations("classpath:db/migration") - .baselineOnMigrate(true) - .load(); - flyway.migrate(); - MigrationInfoService info = flyway.info(); - Assertions.assertThat(info.current().getVersion().getVersion()).isEqualTo("1"); - } -} diff --git a/src/test/java/com/kuit/kupage/unit/common/S3ServiceTest.java b/src/test/java/com/kuit/kupage/unit/common/S3ServiceTest.java index 5a36067..4bb6ecc 100644 --- a/src/test/java/com/kuit/kupage/unit/common/S3ServiceTest.java +++ b/src/test/java/com/kuit/kupage/unit/common/S3ServiceTest.java @@ -1,25 +1,26 @@ package com.kuit.kupage.unit.common; -import com.amazonaws.AmazonServiceException; -import com.amazonaws.SdkClientException; -import com.amazonaws.services.s3.AmazonS3Client; -import com.amazonaws.services.s3.model.PutObjectRequest; -import com.amazonaws.services.s3.model.PutObjectResult; import com.kuit.kupage.common.file.S3Service; import com.kuit.kupage.exception.KupageException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectResponse; import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; -import java.nio.charset.StandardCharsets; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; - +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; public class S3ServiceTest { @@ -27,7 +28,17 @@ public class S3ServiceTest { @DisplayName("ํŒŒ์ผ ์—…๋กœ๋“œ ์„ฑ๊ณต") void test() { // given - S3Service s3Service = new S3Service(new TestAmazonClient()); + S3Client s3Client = mock(S3Client.class); + when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))) + .thenReturn(PutObjectResponse.builder().eTag("etag").build()); + + S3Service s3Service = new S3Service(s3Client); + + // ํ…Œ์ŠคํŠธ์—์„œ๋Š” ๋ฐ˜ํ™˜ URL์ด '/image/..', '/file/..' ํ˜•ํƒœ๊ฐ€ ๋˜๋„๋ก cloudFrontUrl์„ ๋นˆ ๋ฌธ์ž์—ด๋กœ ๋‘ก๋‹ˆ๋‹ค. + // (์‹ค์„œ๋น„์Šค์—์„œ๋Š” cloudFrontUrl์ด 'https://xxxx.cloudfront.net' ์ฒ˜๋Ÿผ ๋“ค์–ด๊ฐˆ ์ˆ˜ ์žˆ์Œ) + ReflectionTestUtils.setField(s3Service, "bucketName", "test-bucket"); + ReflectionTestUtils.setField(s3Service, "cloudFrontUrl", ""); + TestMultipartFile image = new TestMultipartFile("testImage.png", "application/png"); TestMultipartFile file = new TestMultipartFile("testFile.pdf", "application/pdf"); @@ -36,60 +47,35 @@ void test() { String url2 = s3Service.uploadFile(file); // then - System.out.println("url1 = " + url1); String[] url1Tokens = url1.split("/"); assertThat(url1Tokens[1]).isEqualTo("image"); - assertThat(url1Tokens[2].endsWith(".png")).isEqualTo(true); + assertThat(url1Tokens[2].endsWith(".png")).isTrue(); - System.out.println("url1 = " + url1); String[] url2Tokens = url2.split("/"); assertThat(url2Tokens[1]).isEqualTo("file"); - assertThat(url2Tokens[2].endsWith(".pdf")).isEqualTo(true); + assertThat(url2Tokens[2].endsWith(".pdf")).isTrue(); } @Test @DisplayName("ํŒŒ์ผ ์—…๋กœ๋“œ ์‹คํŒจ") void testFail() { // given - S3Service s3Service = new S3Service(new TestAmazonClient()); + S3Client s3Client = mock(S3Client.class); + when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))) + .thenThrow(new RuntimeException("์—…๋กœ๋“œ ์‹คํŒจ")); + + S3Service s3Service = new S3Service(s3Client); + ReflectionTestUtils.setField(s3Service, "bucketName", "test-bucket"); + ReflectionTestUtils.setField(s3Service, "cloudFrontUrl", ""); + TestMultipartFile image = new TestMultipartFile("testFailImage.png", "application/png"); TestMultipartFile file = new TestMultipartFile("testFailFile.pdf", "application/pdf"); - // when - // then assertThrows(KupageException.class, () -> s3Service.uploadImage(image)); assertThrows(KupageException.class, () -> s3Service.uploadFile(file)); } - static class TestAmazonClient extends AmazonS3Client { - @Override - public PutObjectResult putObject(PutObjectRequest putObjectRequest) throws SdkClientException, AmazonServiceException { - String key = putObjectRequest.getKey(); - long contentLength = putObjectRequest.getMetadata().getContentLength(); - String contentType = putObjectRequest.getMetadata().getContentType(); - String content = ""; - try { - byte[] bytes = putObjectRequest.getInputStream().readAllBytes(); - content = new String(bytes, StandardCharsets.UTF_8); - } catch (IOException e) { - throw new RuntimeException(e); - } - - System.out.println("===== ์—…๋กœ๋“œ ํŒŒ์ผ ์ •๋ณด ====="); - System.out.println("key = " + key); - System.out.println("content = " + content); - System.out.println("contentLength = " + contentLength); - System.out.println("contentType = " + contentType); - System.out.println(); - - if (content.contains("Fail")) - throw new AmazonServiceException("์—…๋กœ๋“œ ์‹คํŒจ"); - - return new PutObjectResult(); - } - } - static class TestMultipartFile implements MultipartFile { public TestMultipartFile(String data, String type) { @@ -97,8 +83,8 @@ public TestMultipartFile(String data, String type) { this.type = type; } - private String data; - private String type; + private final String data; + private final String type; @Override public String getName() { @@ -126,18 +112,18 @@ public long getSize() { } @Override - public byte[] getBytes() throws IOException { + public byte[] getBytes() { return data.getBytes(); } @Override - public InputStream getInputStream() throws IOException { + public InputStream getInputStream() { return new ByteArrayInputStream(data.getBytes()); } @Override public void transferTo(File dest) throws IOException, IllegalStateException { - + // no-op for test } } } diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 048790f..4a8e9ce 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -42,6 +42,18 @@ spring: token-uri: https://discord.com/api/oauth2/token user-info-uri: https://discord.com/api/users/@me user-name-attribute: id + cloud: + aws: + credentials: + access-key: TEST_ACCESS_KEY_ABC123 + secret-key: TEST_SECRET_KEY_XYZ789 + region: + static: ap-northeast-2 + sqs: + listener: + poll-timeout: 20s + max-messages-per-poll: 10 + message-visibility: 60s discord: guild-id: 999999999999999999 @@ -53,14 +65,11 @@ cloud: bucket-name: test-kuitee cloudfront: deploy-url: https://files.test-kuitee.p-e.kr - credentials: - access-key: TEST_ACCESS_KEY_ABC123 - secret-key: TEST_SECRET_KEY_XYZ789 - region: - static: ap-northeast-2 - auto: false - stack: - auto: false + +sqs: + queue-url: https://sqs.ap-northeast-2.amazonaws.com/12341234/test-sqs + queue-name: queue-name + secret: jwt: key: testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttest