Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 48 additions & 6 deletions .github/workflows/Github-Action-CD.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -29,19 +37,28 @@ 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
with:
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/[email protected]
Expand All @@ -54,6 +71,26 @@ jobs:
target: "/home/ubuntu/migrations/"
strip_components: 4

- name: Upload docker compose files to EC2 (overwrite)
uses: appleboy/[email protected]
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/[email protected]
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/[email protected]
Expand All @@ -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 }}
Expand All @@ -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)"
Expand All @@ -98,5 +141,4 @@ jobs:
-password="$PROD_DB_PASSWORD" \
migrate


sudo /home/ubuntu/deploy.sh
6 changes: 4 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ repositories {
}

ext {
set('springCloudVersion', "2024.0.0")
set('springCloudVersion', "2024.0.3")
}

dependencies {
Expand Down Expand Up @@ -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'
Expand All @@ -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"
}
}

Expand Down
1 change: 1 addition & 0 deletions discord-bot/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
node_modules/
13 changes: 13 additions & 0 deletions discord-bot/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
FROM node:20-alpine

WORKDIR /app

# 의존성 설치
COPY package*.json ./
RUN npm ci --omit=dev

# 소스 복사
COPY . .

# 실행
CMD ["node", "index.js"]
106 changes: 106 additions & 0 deletions discord-bot/bot.js
Original file line number Diff line number Diff line change
@@ -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);
1 change: 1 addition & 0 deletions discord-bot/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import "./bot.js";
Loading