From 6a94d3473338e6c78ef9c37e4a43984add2ad432 Mon Sep 17 00:00:00 2001 From: ssum1ra <101113206+ssum1ra@users.noreply.github.com> Date: Mon, 25 Nov 2024 18:38:58 +0900 Subject: [PATCH] feat: Implement CLOVA Studio integration for drawing word generation (#121) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Install axios for HTTP requests * feat: Implement CLOVA Studio integration for drawing word generation * chore: Add CLOVA API keys to CI/CD workflow --------- Co-authored-by: 유미라 --- .github/workflows/server-ci-cd.yml | 6 ++- pnpm-lock.yaml | 43 +++++++++++++++ server/package.json | 4 +- server/src/common/clova-client.ts | 60 +++++++++++++++++++++ server/src/common/enums/game.status.enum.ts | 6 +++ server/src/game/game.gateway.ts | 9 ++-- server/src/game/game.module.ts | 3 +- server/src/game/game.service.ts | 13 +++-- 8 files changed, 135 insertions(+), 9 deletions(-) create mode 100644 server/src/common/clova-client.ts diff --git a/.github/workflows/server-ci-cd.yml b/.github/workflows/server-ci-cd.yml index fdadd651..f33c58d8 100644 --- a/.github/workflows/server-ci-cd.yml +++ b/.github/workflows/server-ci-cd.yml @@ -31,6 +31,8 @@ jobs: run: | echo "REDIS_HOST=${{ secrets.REDIS_HOST }}" >> .env echo "REDIS_PORT=${{ secrets.REDIS_PORT }}" >> .env + echo "CLOVA_API_KEY=${{ secrets.CLOVA_API_KEY }}" >> .env + echo "CLOVA_GATEWAY_KEY=${{ secrets.CLOVA_GATEWAY_KEY }}" >> .env - name: Run tests run: pnpm --filter server test | true @@ -62,6 +64,8 @@ jobs: build-args: | REDIS_HOST=${{ secrets.REDIS_HOST }} REDIS_PORT=${{ secrets.REDIS_PORT }} + CLOVA_API_KEY=${{ secrets.CLOVA_API_KEY }} + CLOVA_GATEWAY_KEY=${{ secrets.CLOVA_GATEWAY_KEY }} - name: Deploy to Server uses: appleboy/ssh-action@v1.0.0 @@ -77,4 +81,4 @@ jobs: docker stop troublepainter || true docker rm troublepainter || true - docker run -d --name troublepainter -p 3000:3000 -e REDIS_HOST=${{ secrets.REDIS_HOST }} -e REDIS_PORT=${{ secrets.REDIS_PORT }} --restart unless-stopped ${{ secrets.DOCKERHUB_USERNAME }}/troublepainter:latest + docker run -d --name troublepainter -p 3000:3000 -e REDIS_HOST=${{ secrets.REDIS_HOST }} -e REDIS_PORT=${{ secrets.REDIS_PORT }} -e CLOVA_API_KEY=${{ secrets.CLOVA_API_KEY }} -e CLOVA_GATEWAY_KEY=${{ secrets.CLOVA_GATEWAY_KEY }} --restart unless-stopped ${{ secrets.DOCKERHUB_USERNAME }}/troublepainter:latest diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e7e9117..60c3fb79 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -208,6 +208,9 @@ importers: '@troublepainter/core': specifier: workspace:* version: link:../core + axios: + specifier: ^1.7.7 + version: 1.7.7 ioredis: specifier: ^5.4.1 version: 5.4.1 @@ -233,6 +236,9 @@ importers: '@nestjs/testing': specifier: ^10.0.0 version: 10.4.8(@nestjs/common@10.4.8(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.8)(@nestjs/platform-express@10.4.8) + '@types/axios': + specifier: ^0.14.4 + version: 0.14.4 '@types/express': specifier: ^5.0.0 version: 5.0.0 @@ -1461,6 +1467,10 @@ packages: '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/axios@0.14.4': + resolution: {integrity: sha512-9JgOaunvQdsQ/qW2OPmE5+hCeUB52lQSolecrFrthct55QekhmXEwT203s20RL+UHtCQc15y3VXpby9E7Kkh/g==} + deprecated: This is a stub types definition. axios provides its own type definitions, so you do not need this installed. + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1943,6 +1953,9 @@ packages: resolution: {integrity: sha512-RE3mdQ7P3FRSe7eqCWoeQ/Z9QXrtniSjp1wUjt5nRC3WIpz5rSCve6o3fsZ2aCpJtrZjSZgjwXAoTO5k4tEI0w==} engines: {node: '>=4'} + axios@1.7.7: + resolution: {integrity: sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -2861,6 +2874,15 @@ packages: flatted@3.3.2: resolution: {integrity: sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==} + follow-redirects@1.15.9: + resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + for-each@0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} @@ -4125,6 +4147,9 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + punycode.js@2.3.1: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} @@ -6444,6 +6469,12 @@ snapshots: '@types/aria-query@5.0.4': {} + '@types/axios@0.14.4': + dependencies: + axios: 1.7.7 + transitivePeerDependencies: + - debug + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.26.2 @@ -7110,6 +7141,14 @@ snapshots: axe-core@4.10.2: {} + axios@1.7.7: + dependencies: + follow-redirects: 1.15.9 + form-data: 4.0.1 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + axobject-query@4.1.0: {} babel-jest@29.7.0(@babel/core@7.26.0): @@ -8265,6 +8304,8 @@ snapshots: flatted@3.3.2: {} + follow-redirects@1.15.9: {} + for-each@0.3.3: dependencies: is-callable: 1.2.7 @@ -9637,6 +9678,8 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-from-env@1.1.0: {} + punycode.js@2.3.1: {} punycode@2.3.1: {} diff --git a/server/package.json b/server/package.json index 06968ef4..93f97d4f 100644 --- a/server/package.json +++ b/server/package.json @@ -20,13 +20,14 @@ "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { - "@troublepainter/core": "workspace:*", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.0.0", "@nestjs/platform-express": "^10.0.0", "@nestjs/platform-socket.io": "^10.4.7", "@nestjs/websockets": "^10.4.7", + "@troublepainter/core": "workspace:*", + "axios": "^1.7.7", "ioredis": "^5.4.1", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", @@ -37,6 +38,7 @@ "@nestjs/cli": "^10.0.0", "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.0.0", + "@types/axios": "^0.14.4", "@types/express": "^5.0.0", "@types/jest": "^29.5.2", "@types/node": "^20.3.1", diff --git a/server/src/common/clova-client.ts b/server/src/common/clova-client.ts new file mode 100644 index 00000000..4986ed0a --- /dev/null +++ b/server/src/common/clova-client.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import axios, { AxiosInstance } from 'axios'; +import { Difficulty } from './enums/game.status.enum'; + +@Injectable() +export class ClovaClient { + private readonly client: AxiosInstance; + + constructor(private configService: ConfigService) { + const apiKey = this.configService.get('CLOVA_API_KEY'); + const gatewayKey = this.configService.get('CLOVA_GATEWAY_KEY'); + + this.client = axios.create({ + baseURL: 'https://clovastudio.stream.ntruss.com/testapp/v1', + headers: { + 'X-NCP-CLOVASTUDIO-API-KEY': apiKey, + 'X-NCP-APIGW-API-KEY': gatewayKey, + }, + }); + } + async getDrawingWords(difficulty: Difficulty, count: number) { + const request = { + messages: [ + { + role: 'system', + content: '', + }, + { + role: 'user', + content: `당신은 드로잉 게임의 출제자입니다. ${difficulty}난이도의 명사 ${count}개를 제시해주세요. + - 30초 안에 그릴 수 있는 단어만 선택 + - 단어만 나열 (1. 2. 3. 형식) + - 설명이나 부연설명 없이 단어만 작성 + `, + }, + ], + topP: 0.8, + topK: 0, + maxTokens: 256, + temperature: 0.8, + repeatPenalty: 5.0, + stopBefore: [], + includeAiFilters: true, + seed: Math.floor(Math.random() * 1000000), + }; + + try { + const response = await this.client.post('/chat-completions/HCX-003', request); + const result = response.data.result.message.content + .split('\n') + .map((line: string) => line.trim()) + .filter((line: string) => line) + .map((line: string) => line.replace(/^\d+\.\s*/, '')); + return result; + } catch (error) { + throw new Error(`CLOVA API request failed: ${error.message}`); + } + } +} diff --git a/server/src/common/enums/game.status.enum.ts b/server/src/common/enums/game.status.enum.ts index 9cf69c6b..a5a88280 100644 --- a/server/src/common/enums/game.status.enum.ts +++ b/server/src/common/enums/game.status.enum.ts @@ -14,3 +14,9 @@ export enum RoomStatus { DRAWING = 'DRAWING', GUESSING = 'GUESSING', } + +export enum Difficulty { + EASY = 'EASY', + NORMAL = 'NORMAL', + HARD = 'HARD', +} diff --git a/server/src/game/game.gateway.ts b/server/src/game/game.gateway.ts index 966b443d..c591e24d 100644 --- a/server/src/game/game.gateway.ts +++ b/server/src/game/game.gateway.ts @@ -84,10 +84,10 @@ export class GameGateway implements OnGatewayDisconnect { const { room, roomSettings, roles, players } = await this.gameService.startGame(roomId, playerId); - this.timerService.startTimer(this.server, roomId, 35000, { + this.timerService.startTimer(this.server, roomId, roomSettings.drawTime * 1000, { onTick: async (remaining: number) => { this.server.to(roomId).emit('timerSync', { remaining }); - }, // 35 seconds + }, onTimeUp: async () => { console.log('Game ended'); this.server.to(roomId).emit('drawingTimeEnded'); @@ -108,7 +108,10 @@ export class GameGateway implements OnGatewayDisconnect { }; if (player.role === PlayerRole.PAINTER || player.role === PlayerRole.DEVIL) { - this.server.to(playerSocket.id).emit('drawingGroupRoundStarted', basePayload); + this.server.to(playerSocket.id).emit('drawingGroupRoundStarted', { + ...basePayload, + word: room.currentWord, + }); } else { this.server.to(playerSocket.id).emit('guesserRoundStarted', { ...basePayload, diff --git a/server/src/game/game.module.ts b/server/src/game/game.module.ts index cef924f3..6877f9b4 100644 --- a/server/src/game/game.module.ts +++ b/server/src/game/game.module.ts @@ -5,10 +5,11 @@ import { GameGateway } from './game.gateway'; import { RedisModule } from 'src/redis/redis.module'; import { GameRepository } from './game.repository'; import { TimerService } from 'src/common/services/timer.service'; +import { ClovaClient } from 'src/common/clova-client'; @Module({ imports: [RedisModule], - providers: [GameService, GameGateway, GameRepository, TimerService], + providers: [GameService, GameGateway, GameRepository, TimerService, ClovaClient], controllers: [GameController], }) export class GameModule {} diff --git a/server/src/game/game.service.ts b/server/src/game/game.service.ts index 355c7657..fa95aa02 100644 --- a/server/src/game/game.service.ts +++ b/server/src/game/game.service.ts @@ -9,7 +9,8 @@ import { BadRequestException, InsufficientPlayersException, } from 'src/exceptions/game.exception'; -import { RoomStatus, PlayerStatus, PlayerRole } from 'src/common/enums/game.status.enum'; +import { RoomStatus, PlayerStatus, PlayerRole, Difficulty } from 'src/common/enums/game.status.enum'; +import { ClovaClient } from 'src/common/clova-client'; @Injectable() export class GameService { @@ -18,8 +19,12 @@ export class GameService { totalRounds: 5, drawTime: 35, }; + private words: string[] = []; - constructor(private readonly gameRepository: GameRepository) {} + constructor( + private readonly gameRepository: GameRepository, + private readonly clovaClient: ClovaClient, + ) {} async createRoom(): Promise { const roomId = v4(); @@ -138,10 +143,12 @@ export class GameService { const roomSettings = await this.gameRepository.getRoomSettings(roomId); + this.words = await this.clovaClient.getDrawingWords(Difficulty.HARD, roomSettings.totalRounds); + const roomUpdates = { status: RoomStatus.DRAWING, currentRound: room.currentRound + 1, - currentWord: '바보', + currentWord: this.words.shift(), }; await this.gameRepository.updateRoom(roomId, roomUpdates);