From 37cfbd44dc6a83ba91e9b6031fa12e1448acefc9 Mon Sep 17 00:00:00 2001 From: eastlight82 Date: Mon, 19 Jan 2026 10:28:38 +0900 Subject: [PATCH 1/9] =?UTF-8?q?Feat:=20=EC=84=A4=EB=AA=85=20md=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/codebase-analysis.md | 389 ++++++++++++++++++ docs.txt => docs/docs.txt | 0 docs/security-config-analysis.md | 138 +++++++ .../chatbot/service/WendyServiceImpl.java | 2 +- src/main/resources/application.yaml | 2 +- 5 files changed, 529 insertions(+), 2 deletions(-) create mode 100644 docs/codebase-analysis.md rename docs.txt => docs/docs.txt (100%) create mode 100644 docs/security-config-analysis.md diff --git a/docs/codebase-analysis.md b/docs/codebase-analysis.md new file mode 100644 index 0000000..f9cff89 --- /dev/null +++ b/docs/codebase-analysis.md @@ -0,0 +1,389 @@ +# WorkingDead Backend - Codebase 분석 + +## 프로젝트 개요 + +**When:D (웬디)** - 디스코드 기반 일정 조율 서비스 + +팀/그룹의 회식, 모임 일정을 조율하기 위한 투표 시스템을 제공하며, 디스코드 봇을 통해 자동화된 투표 생성 및 알림 기능을 제공합니다. + +--- + +## 기술 스택 + +| 구분 | 기술 | +|------|------| +| **Language** | Java 21 | +| **Framework** | Spring Boot 3.5.7 | +| **Database** | PostgreSQL (AWS RDS) | +| **ORM** | Spring Data JPA + Hibernate | +| **Security** | Spring Security | +| **Session** | Spring Session JDBC | +| **Cache** | Redis | +| **API Docs** | SpringDoc OpenAPI (Swagger) | +| **Discord Bot** | JDA 5.0.0-beta.24 | +| **Build Tool** | Gradle | + +--- + +## 프로젝트 구조 + +``` +src/main/java/com/workingdead/ +├── WorkingdeadApplication.java # 메인 애플리케이션 +│ +├── config/ # 설정 클래스 +│ ├── SecurityConfig.java # Spring Security 설정 +│ ├── CorsConfig.java # CORS 설정 +│ ├── OpenApiConfig.java # Swagger 설정 +│ └── DiscordBotConfig.java # Discord JDA 설정 +│ +├── enum/ +│ └── Period.java # 시간대 enum (LUNCH/DINNER) +│ +├── meet/ # 핵심 도메인 (투표 시스템) +│ ├── controller/ +│ │ ├── VoteController.java # 투표 CRUD API +│ │ ├── ParticipantController.java # 참여자 관리 API +│ │ ├── VoteResultController.java # 투표 결과 조회 API +│ │ └── VoteDateRangeController.java # 날짜 범위 조회 API +│ │ +│ ├── service/ +│ │ ├── VoteService.java # 투표 비즈니스 로직 +│ │ ├── ParticipantService.java # 참여자 비즈니스 로직 +│ │ ├── VoteResultService.java # 결과 집계 로직 +│ │ ├── VoteDateRangeService.java # 날짜 범위 로직 +│ │ └── PriorityService.java # 우선순위 로직 +│ │ +│ ├── entity/ +│ │ ├── Vote.java # 투표 엔티티 +│ │ ├── Participant.java # 참여자 엔티티 +│ │ ├── ParticipantSelection.java # 일정 선택 엔티티 +│ │ └── PriorityPreference.java # 우선순위 엔티티 +│ │ +│ ├── repository/ +│ │ ├── VoteRepository.java +│ │ ├── ParticipantRepository.java +│ │ ├── ParticipantSelectionRepository.java +│ │ └── PriorityPreferenceRepository.java +│ │ +│ ├── dto/ +│ │ ├── VoteDtos.java +│ │ ├── ParticipantDtos.java +│ │ ├── VoteResultDtos.java +│ │ ├── VoteDateRangeDtos.java +│ │ └── PriorityDtos.java +│ │ +│ └── application/ +│ └── VoteApplicationService.java # 애플리케이션 서비스 +│ +└── chatbot/ # Discord 봇 모듈 + ├── command/ + │ └── WendyCommand.java # 디스코드 명령어 핸들러 + │ + ├── service/ + │ ├── WendyService.java # 봇 서비스 인터페이스 + │ ├── WendyServiceImpl.java # 봇 서비스 구현체 + │ └── WendyNotifier.java # 알림 서비스 + │ + ├── scheduler/ + │ └── WendyScheduler.java # 스케줄러 (리마인드) + │ + └── dto/ + └── VoteResult.java +``` + +--- + +## 핵심 도메인 모델 + +### ERD (Entity Relationship) + +``` +┌─────────────────┐ +│ Vote │ +├─────────────────┤ +│ id (PK) │ +│ name │ +│ code (unique) │ +│ startDate │ +│ endDate │ +│ createdAt │ +└────────┬────────┘ + │ 1:N + ▼ +┌─────────────────┐ +│ Participant │ +├─────────────────┤ +│ id (PK) │ +│ vote_id (FK) │ +│ displayName │ +│ submitted │ +│ submittedAt │ +└────────┬────────┘ + │ 1:N 1:N + ├──────────────────────────┐ + ▼ ▼ +┌─────────────────────┐ ┌─────────────────────┐ +│ ParticipantSelection│ │ PriorityPreference │ +├─────────────────────┤ ├─────────────────────┤ +│ id (PK) │ │ id (PK) │ +│ participant_id (FK) │ │ participant_id (FK) │ +│ vote_id (FK) │ │ vote_id (FK) │ +│ date │ │ date │ +│ period (LUNCH/DINNER│ │ period │ +│ selected │ │ priorityIndex (1~3) │ +└─────────────────────┘ │ weight │ + │ createdAt │ + └─────────────────────┘ +``` + +### 엔티티 설명 + +| 엔티티 | 설명 | +|--------|------| +| **Vote** | 투표 세션. 고유 `code`로 공유 링크 생성 | +| **Participant** | 투표 참여자. Vote에 종속 | +| **ParticipantSelection** | 참여자의 날짜/시간대 선택 (true/false) | +| **PriorityPreference** | 참여자의 우선순위 (1순위, 2순위, 3순위) | + +--- + +## API 엔드포인트 + +### Vote API (`/votes`) + +| Method | Endpoint | 설명 | +|--------|----------|------| +| GET | `/votes` | 전체 투표 목록 조회 | +| GET | `/votes/{id}` | 투표 상세 조회 | +| GET | `/votes/share/{code}` | 공유 코드로 투표 조회 | +| POST | `/votes` | 새 투표 생성 | +| PATCH | `/votes/{id}` | 투표 정보 수정 | +| DELETE | `/votes/{id}` | 투표 삭제 | +| GET | `/votes/{voteId}/dateRange` | 날짜 범위 조회 | +| GET | `/votes/{voteId}/result` | 투표 결과 조회 | + +### Participant API + +| Method | Endpoint | 설명 | +|--------|----------|------| +| GET | `/votes/{voteId}/participants` | 참여자 목록 조회 | +| POST | `/votes/{voteId}/participants` | 참여자 추가 | +| PATCH | `/participants/{id}` | 참여자 정보 수정 | +| DELETE | `/participants/{id}` | 참여자 삭제 | +| GET | `/participants/{id}/choices` | 참여자 선택 정보 조회 | +| PATCH | `/participants/{id}/schedule` | 일정 제출 | +| POST | `/participants/{id}` | 우선순위 설정 | + +--- + +## 투표 결과 집계 로직 + +### 정렬 기준 (VoteResultService) + +``` +1순위: 투표 인원수 (많을수록 상위) +2순위: 우선순위 Index 합계 (작을수록 상위) +3순위: 날짜 (빠를수록 상위) +``` + +### 예시 + +| 날짜 | 시간대 | 인원 | 우선순위합 | 순위 | +|------|--------|------|------------|------| +| 01/20 | LUNCH | 5명 | 3 | 1위 | +| 01/21 | DINNER | 5명 | 7 | 2위 | +| 01/19 | LUNCH | 4명 | 2 | 3위 | + +--- + +## Discord Bot (웬디) + +### 아키텍처 + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ WendyCommand │────▶│ WendyService │────▶│ VoteService │ +│ (ListenerAdapter│ │ Impl │ │ Participant │ +│ 이벤트 핸들러) │ │ (세션 관리) │ │ Service │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ + ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ +│ WendyScheduler │────▶│ WendyNotifier │ +│ (시간 기반 │ │ (메시지 전송) │ +│ 태스크 관리) │ └─────────────────┘ +└─────────────────┘ +``` + +### 명령어 + +| 명령어 | 설명 | +|--------|------| +| `웬디 시작` | 일정 조율 세션 시작 | +| `웬디 종료` | 세션 종료 | +| `웬디 재투표` | 동일 참석자로 새 투표 생성 | +| `웬디 도움말` / `/help` | 도움말 표시 | + +### 알림 스케줄 + +| 시간 | 알림 내용 | +|------|----------| +| 10분 후 | 투표 현황 공유 | +| 15분 후 | 미투표자 독촉 (1차) | +| 1시간 후 | 미투표자 독촉 (2차) | +| 6시간 후 | 미투표자 독촉 (3차) | +| 12시간 후 | 미투표자 독촉 (4차) | +| 24시간 후 | 최후통첩 (1순위로 확정 안내) | + +### 세션 관리 (WendyServiceImpl) + +```java +// 채널별 상태 관리 +private final Set activeSessions; // 활성 세션 +private final Map> participants; // 참석자 +private final Map channelVoteId; // 채널 -> 투표ID +private final Map channelShareUrl; // 채널 -> 공유URL +``` + +--- + +## 설정 파일 + +### application.yaml + +```yaml +spring: + datasource: + url: jdbc:postgresql://[RDS_HOST]:5432/workingdead + username: postgres + password: ${DB_PASSWORD} + + jpa: + hibernate: + ddl-auto: update + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + + session: + store-type: jdbc + +server: + port: 8080 + +discord: + token: ${DISCORD_TOKEN} +``` + +### 환경 변수 + +| 변수명 | 설명 | +|--------|------| +| `DB_PASSWORD` | PostgreSQL 비밀번호 | +| `DISCORD_TOKEN` | Discord Bot 토큰 | +| `AWS_ACCESS_KEY_ID` | AWS 액세스 키 | +| `AWS_SECRET_ACCESS_KEY` | AWS 시크릿 키 | + +--- + +## 보안 설정 + +### 현재 상태 + +```java +// SecurityConfig.java +.authorizeHttpRequests(auth -> auth + .requestMatchers("/v3/api-docs/**", "/swagger-ui/**").permitAll() + .anyRequest().permitAll() // 모든 요청 허용 +); +``` + +- **인증**: 미구현 (모든 API 공개) +- **CSRF**: 비활성화 +- **CORS**: 지정된 도메인만 허용 + +### 허용 도메인 (CorsConfig) + +- localhost:3000, 5173, 8080, 8081 +- whend.app (HTTP/HTTPS) +- whendy.netlify.app + +--- + +## 핵심 비즈니스 플로우 + +### 1. 투표 생성 플로우 + +``` +1. 디스코드에서 "웬디 시작" 입력 +2. 참석자 선택 (드롭다운 메뉴) +3. 주차 선택 (이번 주 ~ 6주 뒤) +4. Vote 엔티티 생성 + Participant 일괄 생성 +5. 공유 URL 반환 (whendy.netlify.app/v/{code}) +6. 스케줄러 시작 (리마인드 알림) +``` + +### 2. 투표 참여 플로우 + +``` +1. 공유 URL 접속 +2. 참여자 칩 선택 (본인 선택) +3. 날짜/시간대 선택 (LUNCH/DINNER) +4. 우선순위 설정 (1~3순위, 선택사항) +5. 제출 → ParticipantSelection, PriorityPreference 저장 +``` + +### 3. 결과 조회 플로우 + +``` +1. GET /votes/{voteId}/result +2. 선택된 일정 집계 (selected=true) +3. 우선순위 가중치 계산 +4. 정렬: 인원 > 우선순위합 > 날짜 +5. 상위 3개 랭킹 반환 +``` + +--- + +## 의존성 목록 (build.gradle) + +```gradle +// Core +implementation 'org.springframework.boot:spring-boot-starter-web' +implementation 'org.springframework.boot:spring-boot-starter-data-jpa' +implementation 'org.springframework.boot:spring-boot-starter-security' +implementation 'org.springframework.boot:spring-boot-starter-validation' + +// Database +runtimeOnly 'org.postgresql:postgresql' +runtimeOnly 'com.h2database:h2' +implementation 'org.flywaydb:flyway-core' + +// Session & Cache +implementation 'org.springframework.session:spring-session-jdbc' +implementation 'org.springframework.boot:spring-boot-starter-data-redis' + +// Discord Bot +implementation 'net.dv8tion:JDA:5.0.0-beta.24' + +// API Docs +implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.8' + +// Utilities +compileOnly 'org.projectlombok:lombok' +``` + +--- + +## 프로젝트 특징 + +1. **Discord 연동**: JDA 라이브러리로 디스코드 봇 구현 +2. **실시간 알림**: 스케줄러 기반 자동 리마인드 +3. **우선순위 시스템**: 단순 투표가 아닌 가중치 기반 결과 도출 +4. **공유 URL**: 8자리 고유 코드로 간편한 공유 +5. **세션 관리**: 채널별 독립적인 세션 상태 관리 + +--- + +*문서 생성일: 2026-01-19* \ No newline at end of file diff --git a/docs.txt b/docs/docs.txt similarity index 100% rename from docs.txt rename to docs/docs.txt diff --git a/docs/security-config-analysis.md b/docs/security-config-analysis.md new file mode 100644 index 0000000..3235a23 --- /dev/null +++ b/docs/security-config-analysis.md @@ -0,0 +1,138 @@ +# Security Configuration 분석 + +## 개요 + +이 문서는 WorkingDead 백엔드 프로젝트의 보안 설정을 분석한 문서입니다. + +--- + +## 파일 구조 + +``` +src/main/java/com/workingdead/config/ +├── SecurityConfig.java # Spring Security 설정 +└── CorsConfig.java # CORS 설정 +``` + +--- + +## 1. SecurityConfig.java + +### 위치 +`src/main/java/com/workingdead/config/SecurityConfig.java` + +### 역할 +Spring Security의 핵심 보안 설정을 담당합니다. + +### 주요 설정 + +| 설정 항목 | 값 | 설명 | +|-----------|-----|------| +| CORS | 활성화 | CorsConfig에서 정의한 설정 사용 | +| CSRF | 비활성화 | REST API 서버이므로 CSRF 토큰 불필요 | +| Form Login | 비활성화 | 기본 로그인 폼 사용 안함 | +| HTTP Basic | 비활성화 | 브라우저 팝업 로그인 사용 안함 | + +### 접근 권한 설정 + +```java +.authorizeHttpRequests(auth -> auth + .requestMatchers( + "/v3/api-docs/**", + "/swagger-ui/**", + "/swagger-ui.html" + ).permitAll() + .anyRequest().permitAll() +); +``` + +**현재 상태**: 모든 요청에 대해 `permitAll()` 설정 +- Swagger 문서 경로: `/v3/api-docs/**`, `/swagger-ui/**`, `/swagger-ui.html` +- 기타 모든 요청: 인증 없이 접근 가능 + +### 보안 수준 +현재는 **인증이 구현되어 있지 않은 상태**입니다. 모든 API 엔드포인트가 공개되어 있습니다. + +--- + +## 2. CorsConfig.java + +### 위치 +`src/main/java/com/workingdead/config/CorsConfig.java` + +### 역할 +Cross-Origin Resource Sharing (CORS) 정책을 정의합니다. + +### 허용된 Origin 목록 + +| Origin | 환경 | +|--------|------| +| `http://localhost:3000` | 로컬 개발 (React 기본 포트) | +| `http://localhost:8081` | 로컬 개발 | +| `http://localhost:8080` | 로컬 개발 | +| `http://10.0.2.2:8080` | Android 에뮬레이터 | +| `http://localhost:5173` | 로컬 개발 (Vite 기본 포트) | +| `http://whend.app` | 프로덕션 (HTTP) | +| `https://whend.app` | 프로덕션 (HTTPS) | +| `https://whendy.netlify.app` | Netlify 배포 | + +### CORS 상세 설정 + +| 설정 | 값 | 설명 | +|------|-----|------| +| Allowed Methods | GET, POST, PUT, PATCH, DELETE, OPTIONS | 모든 REST 메서드 허용 | +| Allowed Headers | `*` | 모든 헤더 허용 | +| Exposed Headers | Authorization, Content-Type | 클라이언트에서 접근 가능한 응답 헤더 | +| Allow Credentials | `true` | 쿠키/인증 정보 포함 허용 | +| Max Age | 3600초 (1시간) | Preflight 요청 캐시 시간 | + +--- + +## 현재 인증 상태 요약 + +``` +┌─────────────────────────────────────────────────────┐ +│ 현재 상태 │ +├─────────────────────────────────────────────────────┤ +│ - JWT 인증: 미구현 │ +│ - OAuth 로그인: 미구현 │ +│ - Session 기반 인증: 미구현 │ +│ - 모든 API: 공개 접근 가능 (permitAll) │ +└─────────────────────────────────────────────────────┘ +``` + +--- + +## 향후 보안 강화 시 고려사항 + +1. **JWT 토큰 인증 도입** + - Access Token / Refresh Token 구조 + - JwtAuthenticationFilter 추가 + +2. **OAuth2 소셜 로그인** + - Google, Kakao, Naver 등 연동 + - OAuth2LoginSuccessHandler 구현 + +3. **엔드포인트별 권한 분리** + ```java + .requestMatchers("/api/admin/**").hasRole("ADMIN") + .requestMatchers("/api/user/**").authenticated() + .anyRequest().permitAll() + ``` + +4. **Rate Limiting** + - API 호출 횟수 제한으로 DDoS 방어 + +--- + +## 관련 의존성 + +`build.gradle`에 Spring Security가 포함되어 있어야 합니다: + +```gradle +implementation 'org.springframework.boot:spring-boot-starter-security' +``` + +--- + +*문서 생성일: 2026-01-19* \ No newline at end of file diff --git a/src/main/java/com/workingdead/chatbot/service/WendyServiceImpl.java b/src/main/java/com/workingdead/chatbot/service/WendyServiceImpl.java index 5250567..bd2ff76 100644 --- a/src/main/java/com/workingdead/chatbot/service/WendyServiceImpl.java +++ b/src/main/java/com/workingdead/chatbot/service/WendyServiceImpl.java @@ -125,7 +125,7 @@ public void removeParticipant(String channelId, String memberId) { + (removedName != null ? removedName : memberId) + " (discordId=" + memberId + ")"); } else { - // 현재는 디스코드 쪽 참석자 목록에서만 제rㅓ + // 현재는 디스코드 쪽 참석자 목록에서만 제거 System.out.println("[When:D] Participant removed AFTER vote (domain not deleted): " + (removedName != null ? removedName : memberId) + " (discordId=" + memberId + ")"); diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 2691bf5..2f94c29 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -32,7 +32,7 @@ spring: access-key: ${AWS_ACCESS_KEY_ID} secret-key: ${AWS_SECRET_ACCESS_KEY} s3: - bucket: # ??: spring.cloud.aws.s3.bucket? ?? ?????? ?? app.s3.bucket ?? + bucket: # spring.cloud.aws.s3.bucket path-style-access-enabled: false logging: From c57f9a2bc2c88b3bcc612486ae5811b89b50c519 Mon Sep 17 00:00:00 2001 From: eastlight82 Date: Mon, 19 Jan 2026 16:14:54 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20kakao=20chatbot=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../discord/command/DiscordWendyCommand.java | 257 +++++++++++ .../discord/dto/DiscordVoteResult.java | 60 +++ .../scheduler/DiscordWendyScheduler.java | 50 +++ .../discord/service/DiscordWendyNotifier.java | 97 +++++ .../discord/service/DiscordWendyService.java | 28 ++ .../service/DiscordWendyServiceImpl.java | 288 +++++++++++++ .../controller/KakaoSkillController.java | 191 +++++++++ .../chatbot/kakao/dto/KakaoRequest.java | 124 ++++++ .../chatbot/kakao/dto/KakaoResponse.java | 274 ++++++++++++ .../kakao/scheduler/KakaoWendyScheduler.java | 87 ++++ .../chatbot/kakao/service/KakaoNotifier.java | 201 +++++++++ .../kakao/service/KakaoWendyService.java | 405 ++++++++++++++++++ .../com/workingdead/config/KakaoConfig.java | 27 ++ 13 files changed, 2089 insertions(+) create mode 100644 src/main/java/com/workingdead/chatbot/discord/command/DiscordWendyCommand.java create mode 100644 src/main/java/com/workingdead/chatbot/discord/dto/DiscordVoteResult.java create mode 100644 src/main/java/com/workingdead/chatbot/discord/scheduler/DiscordWendyScheduler.java create mode 100644 src/main/java/com/workingdead/chatbot/discord/service/DiscordWendyNotifier.java create mode 100644 src/main/java/com/workingdead/chatbot/discord/service/DiscordWendyService.java create mode 100644 src/main/java/com/workingdead/chatbot/discord/service/DiscordWendyServiceImpl.java create mode 100644 src/main/java/com/workingdead/chatbot/kakao/controller/KakaoSkillController.java create mode 100644 src/main/java/com/workingdead/chatbot/kakao/dto/KakaoRequest.java create mode 100644 src/main/java/com/workingdead/chatbot/kakao/dto/KakaoResponse.java create mode 100644 src/main/java/com/workingdead/chatbot/kakao/scheduler/KakaoWendyScheduler.java create mode 100644 src/main/java/com/workingdead/chatbot/kakao/service/KakaoNotifier.java create mode 100644 src/main/java/com/workingdead/chatbot/kakao/service/KakaoWendyService.java create mode 100644 src/main/java/com/workingdead/config/KakaoConfig.java diff --git a/src/main/java/com/workingdead/chatbot/discord/command/DiscordWendyCommand.java b/src/main/java/com/workingdead/chatbot/discord/command/DiscordWendyCommand.java new file mode 100644 index 0000000..f72b5ed --- /dev/null +++ b/src/main/java/com/workingdead/chatbot/discord/command/DiscordWendyCommand.java @@ -0,0 +1,257 @@ +package com.workingdead.chatbot.discord.command; + +import com.workingdead.chatbot.discord.scheduler.DiscordWendyScheduler; +import com.workingdead.chatbot.discord.service.DiscordWendyService; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.events.interaction.component.EntitySelectInteractionEvent; +import net.dv8tion.jda.api.events.interaction.component.StringSelectInteractionEvent; +import net.dv8tion.jda.api.events.message.MessageReceivedEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import net.dv8tion.jda.api.interactions.components.selections.EntitySelectMenu; +import net.dv8tion.jda.api.interactions.components.selections.StringSelectMenu; +import org.springframework.stereotype.Component; + +@Component +public class DiscordWendyCommand extends ListenerAdapter { + + private final DiscordWendyService discordWendyService; + private final DiscordWendyScheduler discordWendyScheduler; + + private final Map participantCheckMessages = new ConcurrentHashMap<>(); + private final Map waitingForDateInput = new ConcurrentHashMap<>(); + + private static final String ATTENDEE_SELECT_MENU_ID = "wendy-attendees"; + private static final String WEEK_SELECT_MENU_ID = "wendy-weeks"; + private static final String WEEK_SELECT_MENU_REVOTE_ID = "wendy-weeks-revote"; + + public DiscordWendyCommand(DiscordWendyService discordWendyService, DiscordWendyScheduler discordWendyScheduler) { + this.discordWendyService = discordWendyService; + this.discordWendyScheduler = discordWendyScheduler; + } + + @Override + public void onGuildJoin(net.dv8tion.jda.api.events.guild.GuildJoinEvent event) { + TextChannel defaultChannel = event.getGuild().getDefaultChannel().asTextChannel(); + if (defaultChannel != null) { + defaultChannel.sendMessage(""" + 안녕하세요! 일정 조율 도우미 웬디가 서버에 합류했어요 :D + 일정을 조율하려면 채팅에 **'웬디 시작'** 이라고 입력해 주세요! + """).queue(); + } + } + + @Override + public void onMessageReceived(MessageReceivedEvent event) { + if (event.getAuthor().isBot()) return; + + String content = event.getMessage().getContentRaw().trim(); + TextChannel channel = event.getChannel().asTextChannel(); + String channelId = channel.getId(); + Member member = event.getMember(); + + // 웬디 시작 + if (content.equals("웬디 시작")) { + handleStart(channel); + return; + } + + // 도움말 + if (content.equals("/help") || content.equals("웬디 도움말")) { + handleHelp(channel); + return; + } + + // 세션 체크 + if (!discordWendyService.isSessionActive(channelId)) { + return; + } + + // 재투표 + if (content.equals("웬디 재투표")) { + handleRevote(channel); + return; + } + + // 웬디 종료 + if (content.equals("웬디 종료")) { + handleEnd(channel); + return; + } + } + + @Override + public void onEntitySelectInteraction(EntitySelectInteractionEvent event) { + if (!ATTENDEE_SELECT_MENU_ID.equals(event.getComponentId())) { + return; + } + + String channelId = event.getChannel().getId(); + if (!discordWendyService.isSessionActive(channelId)) { + return; + } + + event.getMentions().getMembers().forEach(member -> { + discordWendyService.addParticipant(channelId, member.getId(), member.getEffectiveName()); + System.out.println("[Discord Command] Participant added via select menu: " + member.getEffectiveName()); + }); + + event.reply("참석자 명단이 업데이트됐어요!").setEphemeral(true).queue(); + } + + @Override + public void onStringSelectInteraction(StringSelectInteractionEvent event) { + String componentId = event.getComponentId(); + if (!WEEK_SELECT_MENU_ID.equals(componentId) && !WEEK_SELECT_MENU_REVOTE_ID.equals(componentId)) { + return; + } + + String channelId = event.getChannel().getId(); + if (!discordWendyService.isSessionActive(channelId)) { + return; + } + + String value = event.getValues().get(0); + int weeks; + try { + weeks = Integer.parseInt(value); + } catch (NumberFormatException e) { + event.reply("선택한 값이 올바르지 않아요. 다시 시도해주세요!").setEphemeral(true).queue(); + return; + } + + TextChannel channel = event.getChannel().asTextChannel(); + Member member = event.getMember(); + if (member == null) { + event.reply("사용자 정보를 가져올 수 없어요. 다시 시도해주세요!").setEphemeral(true).queue(); + return; + } + + boolean isRevote = WEEK_SELECT_MENU_REVOTE_ID.equals(componentId); + handleDateInput(channel, member, weeks, isRevote); + event.reply("투표 날짜 범위를 선택하셨어요!").setEphemeral(true).queue(); + } + + private void handleStart(TextChannel channel) { + String channelId = channel.getId(); + List members = channel.getMembers(); + + discordWendyService.startSession(channelId, members); + + channel.sendMessage(""" + 안녕하세요! 일정 조율 도우미 웬디에요 :D + 지금부터 여러분의 일정 조율을 도와드릴게요 + """).queue(); + + EntitySelectMenu attendeeMenu = EntitySelectMenu.create(ATTENDEE_SELECT_MENU_ID, EntitySelectMenu.SelectTarget.USER) + .setPlaceholder("참석자를 선택 / 검색해 주세요.") + .setRequiredRange(1, 25) + .build(); + + channel.sendMessage("인원 파악을 위해 참석자분들을 알려주세요!\n원하는 참석자들을 아래 드롭다운에서 선택해주세요.") + .setActionRow(attendeeMenu) + .queue(); + + StringSelectMenu weekMenu = StringSelectMenu.create(WEEK_SELECT_MENU_ID) + .setPlaceholder("몇 주 뒤의 일정을 계획하시나요?") + .addOption("이번 주", "0") + .addOption("1주 뒤", "1") + .addOption("2주 뒤", "2") + .addOption("3주 뒤", "3") + .addOption("4주 뒤", "4") + .addOption("5주 뒤", "5") + .addOption("6주 뒤", "6") + .build(); + + channel.sendMessage("몇 주 뒤의 일정을 계획하시나요? :D") + .setActionRow(weekMenu) + .queue(); + } + + private void handleDateInput(TextChannel channel, Member member, int weeks, boolean isRevote) { + String channelId = channel.getId(); + String userMention = member.getAsMention(); + String channelName = channel.getName(); + + waitingForDateInput.put(channelId, false); + + channel.sendMessage(userMention + " 님이 " + weeks + "주 뒤를 선택하셨어요!").queue(); + channel.sendMessage("해당 일정의 투표를 만들어드릴게요 :D").queue(); + channel.sendMessage("(투표 늦게 하는 사람 대머리🧑‍🦲)").queue(); + channel.sendMessage("투표를 생성 중입니다🛜").queue(); + + String voteUrl = isRevote + ? discordWendyService.recreateVote(channelId, channelName, weeks) + : discordWendyService.createVote(channelId, channelName, weeks); + + channel.sendMessage(voteUrl).queue(); + discordWendyScheduler.startSchedule(channel); + } + + private void handleRevote(TextChannel channel) { + String channelId = channel.getId(); + + if (!discordWendyService.hasPreviousVote(channelId)) { + channel.sendMessage("아직 진행된 투표가 없어요🗑️").queue(); + return; + } + + discordWendyScheduler.stopSchedule(channelId); + + StringSelectMenu weekMenu = StringSelectMenu.create(WEEK_SELECT_MENU_REVOTE_ID) + .setPlaceholder("몇 주 뒤의 일정을 다시 계획하시나요?") + .addOption("이번 주", "0") + .addOption("1주 뒤", "1") + .addOption("2주 뒤", "2") + .addOption("3주 뒤", "3") + .addOption("4주 뒤", "4") + .addOption("5주 뒤", "5") + .addOption("6주 뒤", "6") + .build(); + + channel.sendMessage("몇 주 뒤의 일정을 계획하시나요? :D") + .setActionRow(weekMenu) + .queue(); + } + + private void handleEnd(TextChannel channel) { + String channelId = channel.getId(); + + discordWendyScheduler.stopSchedule(channelId); + discordWendyService.endSession(channelId); + + participantCheckMessages.remove(channelId); + waitingForDateInput.remove(channelId); + + channel.sendMessage(""" + 웬디는 여기서 눈치껏 빠질게요 :D + 모두 알찬 시간 보내세요! + """).queue(); + System.out.println("[Discord Command] Session ended: " + channelId); + } + + private void handleHelp(TextChannel channel) { + channel.sendMessage(""" + 웬디는 다음과 같은 기능이 있어요! + + **'웬디 시작'**: 일정 조율을 시작해요 + **'웬디 종료'**: 작동을 종료해요 + **'웬디 재투표'**: 동일한 참석자로 투표를 다시 올려요 + """).queue(); + } + + private Integer extractWeeks(String content) { + String numbers = content.replaceAll("[^0-9]", ""); + if (numbers.isEmpty()) return null; + try { + int weeks = Integer.parseInt(numbers); + if (weeks < 1 || weeks > 12) return null; + return weeks; + } catch (NumberFormatException e) { + return null; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/workingdead/chatbot/discord/dto/DiscordVoteResult.java b/src/main/java/com/workingdead/chatbot/discord/dto/DiscordVoteResult.java new file mode 100644 index 0000000..07ab882 --- /dev/null +++ b/src/main/java/com/workingdead/chatbot/discord/dto/DiscordVoteResult.java @@ -0,0 +1,60 @@ +package com.workingdead.chatbot.discord.dto; + +import java.util.List; + +public class DiscordVoteResult { + private String voteUrl; + private List rankings; + + public DiscordVoteResult() {} + + public DiscordVoteResult(String voteUrl, List rankings) { + this.voteUrl = voteUrl; + this.rankings = rankings; + } + + public boolean isEmpty() { + return rankings == null || rankings.isEmpty(); + } + + public String getVoteUrl() { return voteUrl; } + public void setVoteUrl(String voteUrl) { this.voteUrl = voteUrl; } + public List getRankings() { return rankings; } + public void setRankings(List rankings) { this.rankings = rankings; } + + public static class RankResult { + private int rank; + private String dateTime; + private List voters; + + public RankResult() {} + public RankResult(int rank, String dateTime, List voters) { + this.rank = rank; + this.dateTime = dateTime; + this.voters = voters; + } + + public int getRank() { return rank; } + public String getDateTime() { return dateTime; } + public List getVoters() { return voters; } + } + + public static class Voter { + private String name; + private Integer priority; + + public Voter() {} + public Voter(String name, Integer priority) { + this.name = name; + this.priority = priority; + } + + public String getName() { return name; } + public Integer getPriority() { return priority; } + + @Override + public String toString() { + return priority != null ? name + "(" + priority + ")" : name; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/workingdead/chatbot/discord/scheduler/DiscordWendyScheduler.java b/src/main/java/com/workingdead/chatbot/discord/scheduler/DiscordWendyScheduler.java new file mode 100644 index 0000000..959de56 --- /dev/null +++ b/src/main/java/com/workingdead/chatbot/discord/scheduler/DiscordWendyScheduler.java @@ -0,0 +1,50 @@ +package com.workingdead.chatbot.discord.scheduler; + +import com.workingdead.chatbot.discord.service.DiscordWendyNotifier; +import com.workingdead.chatbot.discord.service.DiscordWendyNotifier.RemindTiming; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.*; + +@Component +public class DiscordWendyScheduler { + + private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2); + private final DiscordWendyNotifier notifier; + private final Map>> channelTasks = new ConcurrentHashMap<>(); + + public DiscordWendyScheduler(DiscordWendyNotifier notifier) { + this.notifier = notifier; + } + + public void startSchedule(TextChannel channel) { + String channelId = channel.getId(); + stopSchedule(channelId); + + CopyOnWriteArrayList> tasks = new CopyOnWriteArrayList<>(); + + // 투표 현황: 10분 후 첫 공유 + tasks.add(scheduler.schedule(() -> notifier.shareVoteStatus(channel), 10, TimeUnit.MINUTES)); + + // 미투표자 독촉 + tasks.add(scheduler.schedule(() -> notifier.remindNonVoters(channel, RemindTiming.MIN_15), 15, TimeUnit.MINUTES)); + tasks.add(scheduler.schedule(() -> notifier.remindNonVoters(channel, RemindTiming.HOUR_1), 1, TimeUnit.HOURS)); + tasks.add(scheduler.schedule(() -> notifier.remindNonVoters(channel, RemindTiming.HOUR_6), 6, TimeUnit.HOURS)); + tasks.add(scheduler.schedule(() -> notifier.remindNonVoters(channel, RemindTiming.HOUR_12), 12, TimeUnit.HOURS)); + tasks.add(scheduler.schedule(() -> notifier.remindNonVoters(channel, RemindTiming.HOUR_24), 24, TimeUnit.HOURS)); + + channelTasks.put(channelId, tasks); + System.out.println("[Discord Scheduler] Schedule started: " + channelId); + } + + public void stopSchedule(String channelId) { + List> tasks = channelTasks.remove(channelId); + if (tasks != null) { + tasks.forEach(task -> task.cancel(false)); + System.out.println("[Discord Scheduler] Schedule stopped: " + channelId); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/workingdead/chatbot/discord/service/DiscordWendyNotifier.java b/src/main/java/com/workingdead/chatbot/discord/service/DiscordWendyNotifier.java new file mode 100644 index 0000000..f70bb62 --- /dev/null +++ b/src/main/java/com/workingdead/chatbot/discord/service/DiscordWendyNotifier.java @@ -0,0 +1,97 @@ +package com.workingdead.chatbot.discord.service; + +import com.workingdead.meet.dto.VoteResultDtos.RankingRes; +import com.workingdead.meet.dto.VoteResultDtos.VoteResultRes; +import java.util.List; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class DiscordWendyNotifier { + + private final DiscordWendyService discordWendyService; + + public void shareVoteStatus(TextChannel channel) { + try { + VoteResultRes result = discordWendyService.getVoteStatus(channel.getId()); + String shareUrl = discordWendyService.getShareUrl(channel.getId()); + + if (result == null || result.rankings() == null || result.rankings().isEmpty()) { + channel.sendMessage(""" + 웬디가 투표 현황을 공유드려요! :D + + 엥 아직 아무도 투표를 안 했네요 :( + + 투표하러 가기: """ + shareUrl + ).queue(); + return; + } + + StringBuilder sb = new StringBuilder(); + sb.append("웬디가 투표 현황을 공유드려요! :D\n"); + + if (shareUrl != null && !shareUrl.isBlank()) { + sb.append("\n투표하러 가기: ").append(shareUrl).append("\n\n"); + } else { + sb.append("\n투표 링크가 준비되지 않았어요 😢\n\n"); + } + + for (RankingRes rank : result.rankings()) { + if (rank.rank() == null) continue; + + String periodLabel = "LUNCH".equals(rank.period()) ? "점심" : "저녁"; + sb.append("📌") + .append(rank.rank()).append("순위 ") + .append(rank.date()).append(" ").append(periodLabel).append("\n"); + + if (rank.voters() != null && !rank.voters().isEmpty()) { + String voterStr = rank.voters().stream() + .map(v -> v.participantName() + + (v.priorityIndex() != null ? "(" + v.priorityIndex() + ")" : "")) + .collect(Collectors.joining(", ")); + sb.append("투표자: ").append(voterStr).append("\n"); + } + sb.append("\n"); + } + + channel.sendMessage(sb.toString()).queue(); + } catch (Exception e) { + System.err.println("[Discord Scheduler] Failed to share vote status: " + e.getMessage()); + } + } + + public void remindNonVoters(TextChannel channel, RemindTiming timing) { + try { + List nonVoterIds = discordWendyService.getNonVoterIds(channel.getId()); + + if (nonVoterIds == null || nonVoterIds.isEmpty()) { + return; + } + + String mentions = nonVoterIds.stream() + .map(id -> "<@" + id + ">") + .collect(Collectors.joining(" ")); + + String message = switch (timing) { + case MIN_15, HOUR_1 -> mentions + " 투표가 시작됐어요! 다른 분들을 위해 빠른 참여 부탁드려요 :D"; + case HOUR_6 -> "다들 " + mentions + " 님의 투표를 기다리고 있어요🙌"; + case HOUR_12 -> mentions + " 웬디 기다리다 지쳐버림…🥹 대머리신가요?"; + case HOUR_24 -> { + String bestDateTime = discordWendyService.getTopRankedDateTime(channel.getId()); + String deadline = discordWendyService.getVoteDeadline(channel.getId()); + yield "최후통첩✉️\n" + mentions + "\n\n: " + deadline + "까지 투표 불참 시, " + bestDateTime + "으로 확정됩니다"; + } + }; + + channel.sendMessage(message).queue(); + System.out.println("[Discord Scheduler] Reminder sent: " + timing); + } catch (Exception e) { + System.err.println("[Discord Scheduler] Failed to send reminder: " + e.getMessage()); + } + } + + public enum RemindTiming { MIN_15, HOUR_1, HOUR_6, HOUR_12, HOUR_24 } +} \ No newline at end of file diff --git a/src/main/java/com/workingdead/chatbot/discord/service/DiscordWendyService.java b/src/main/java/com/workingdead/chatbot/discord/service/DiscordWendyService.java new file mode 100644 index 0000000..6775517 --- /dev/null +++ b/src/main/java/com/workingdead/chatbot/discord/service/DiscordWendyService.java @@ -0,0 +1,28 @@ +package com.workingdead.chatbot.discord.service; + +import com.workingdead.meet.dto.VoteResultDtos.VoteResultRes; +import net.dv8tion.jda.api.entities.Member; + +import java.util.List; + +public interface DiscordWendyService { + void startSession(String channelId, List participants); + boolean isSessionActive(String channelId); + void endSession(String channelId); + + void addParticipant(String channelId, String memberId, String memberName); + void removeParticipant(String channelId, String memberId); + + String createVote(String channelId, String channelName, int weeks); + VoteResultRes getVoteStatus(String channelId); + List getNonVoterIds(String channelId); + + String getShareUrl(String channelId); + boolean hasPreviousVote(String channelId); + String recreateVote(String channelId, String channelName, int weeks); + + String getVoteDeadline(String channelId); + String getTopRankedDateTime(String channelId); + + String getChannelIdByVoteId(Long voteId); +} \ No newline at end of file diff --git a/src/main/java/com/workingdead/chatbot/discord/service/DiscordWendyServiceImpl.java b/src/main/java/com/workingdead/chatbot/discord/service/DiscordWendyServiceImpl.java new file mode 100644 index 0000000..16a2e50 --- /dev/null +++ b/src/main/java/com/workingdead/chatbot/discord/service/DiscordWendyServiceImpl.java @@ -0,0 +1,288 @@ +package com.workingdead.chatbot.discord.service; + +import com.workingdead.meet.dto.ParticipantDtos.ParticipantRes; +import com.workingdead.meet.dto.ParticipantDtos.ParticipantStatusRes; +import com.workingdead.meet.dto.VoteDtos.CreateVoteReq; +import com.workingdead.meet.dto.VoteDtos.VoteSummary; +import com.workingdead.meet.dto.VoteResultDtos.RankingRes; +import com.workingdead.meet.dto.VoteResultDtos.VoteResultRes; +import com.workingdead.meet.service.ParticipantService; +import com.workingdead.meet.service.VoteResultService; +import com.workingdead.meet.service.VoteService; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import net.dv8tion.jda.api.entities.Member; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + + +@Service +@RequiredArgsConstructor +public class DiscordWendyServiceImpl implements DiscordWendyService { + + private final VoteService voteService; + private final ParticipantService participantService; + private final VoteResultService voteResultService; + + // 활성 세션 관리 (channelId 기반) + private final Set activeSessions = ConcurrentHashMap.newKeySet(); + + // 디스코드 참석자 (channelId -> (discordUserId -> displayName)) + private final Map> participants = new ConcurrentHashMap<>(); + + // 생성된 투표 id (channelId -> voteId) + private final Map channelVoteId = new ConcurrentHashMap<>(); + + // 생성된 투표 링크 + private final Map channelShareUrl = new ConcurrentHashMap<>(); + + // 투표 생성 여부 (재투표 체크용) + private final Set hasVote = ConcurrentHashMap.newKeySet(); + + // 투표 생성 시각 및 기준 주차 + private final Map voteCreatedAt = new ConcurrentHashMap<>(); + private final Map voteWeeks = new ConcurrentHashMap<>(); + + @Override + public void startSession(String channelId, List members) { + activeSessions.add(channelId); + + participants.put(channelId, new ConcurrentHashMap<>()); + + channelVoteId.remove(channelId); + channelShareUrl.remove(channelId); + + voteCreatedAt.remove(channelId); + voteWeeks.remove(channelId); + System.out.println("[Discord When:D] Session started: " + channelId); + } + + @Override + public boolean isSessionActive(String channelId) { + return activeSessions.contains(channelId); + } + + @Override + public void endSession(String channelId) { + activeSessions.remove(channelId); + participants.remove(channelId); + + channelVoteId.remove(channelId); + channelShareUrl.remove(channelId); + + hasVote.remove(channelId); + voteCreatedAt.remove(channelId); + voteWeeks.remove(channelId); + System.out.println("[Discord When:D] Session ended: " + channelId); + } + + @Override + public void addParticipant(String channelId, String memberId, String memberName) { + Map channelParticipants = + participants.computeIfAbsent(channelId, k -> new ConcurrentHashMap<>()); + String previousName = channelParticipants.put(memberId, memberName); + + Long voteId = channelVoteId.get(channelId); + if (voteId == null) { + System.out.println("[Discord When:D] Participant added BEFORE vote: " + memberName + + " (discordId=" + memberId + ")"); + return; + } + + if (previousName != null) { + System.out.println("[Discord When:D] Participant already exists AFTER vote: " + memberName + + " (discordId=" + memberId + ")"); + return; + } + + ParticipantRes pRes = participantService.add(voteId, memberName); + System.out.println("[Discord When:D] Participant added AFTER vote: " + memberName + + " (discordId=" + memberId + ", participantId=" + pRes.id() + ")"); + } + + @Override + public void removeParticipant(String channelId, String memberId) { + Map channelParticipants = participants.get(channelId); + String removedName = null; + if (channelParticipants != null) { + removedName = channelParticipants.remove(memberId); + } + + Long voteId = channelVoteId.get(channelId); + if (voteId == null) { + System.out.println("[Discord When:D] Participant removed BEFORE vote: " + + (removedName != null ? removedName : memberId) + + " (discordId=" + memberId + ")"); + } else { + System.out.println("[Discord When:D] Participant removed AFTER vote (domain not deleted): " + + (removedName != null ? removedName : memberId) + + " (discordId=" + memberId + ")"); + } + } + + + @Override + public String createVote(String channelId, String channelName, int weeks) { + hasVote.add(channelId); + voteCreatedAt.put(channelId, LocalDateTime.now()); + voteWeeks.put(channelId, weeks); + + LocalDate today = LocalDate.now(); + LocalDate startDate; + LocalDate endDate; + + if (weeks == 0) { + startDate = today; + int daysToSunday = DayOfWeek.SUNDAY.getValue() - today.getDayOfWeek().getValue(); + endDate = today.plusDays(Math.max(daysToSunday, 0)); + } else { + LocalDate mondayThisWeek = today.with(DayOfWeek.MONDAY); + startDate = mondayThisWeek.plusWeeks(weeks); + endDate = startDate.plusDays(6); + } + + Map channelParticipants = participants.getOrDefault(channelId, Map.of()); + List participantNames = new ArrayList<>(channelParticipants.values()); + + CreateVoteReq req = new CreateVoteReq( + channelName, + startDate, + endDate, + participantNames.isEmpty() ? null : participantNames + ); + + VoteSummary summary = voteService.create(req); + Long voteId = summary.id(); + String shareUrl = summary.shareUrl(); + channelShareUrl.put(channelId, shareUrl); + + channelVoteId.put(channelId, voteId); + + System.out.println("[Discord When:D] Vote created for channel " + channelId + " (voteId=" + voteId + + ", (weeks=" + weeks + "))"); + return shareUrl; + } + + @Override + public VoteResultRes getVoteStatus(String channelId) { + Long voteId = channelVoteId.get(channelId); + if (voteId == null) { + return null; + } + + return voteResultService.getVoteResult(voteId); + } + + @Override + public List getNonVoterIds(String channelId) { + Long voteId = channelVoteId.get(channelId); + if (voteId == null) { + return List.of(); + } + + Map channelParticipants = participants.getOrDefault(channelId, Map.of()); + if (channelParticipants.isEmpty()) { + return List.of(); + } + + List statuses = + participantService.getParticipantStatusByVoteId(voteId); + + Set nonSubmittedNames = statuses.stream() + .filter(s -> !Boolean.TRUE.equals(s.submitted())) + .map(ParticipantStatusRes::displayName) + .collect(Collectors.toSet()); + + List nonVoters = new ArrayList<>(); + for (Map.Entry entry : channelParticipants.entrySet()) { + if (nonSubmittedNames.contains(entry.getValue())) { + nonVoters.add(entry.getKey()); + } + } + + return nonVoters; + } + + @Override + public boolean hasPreviousVote(String channelId) { + return hasVote.contains(channelId); + } + + @Override + public String recreateVote(String channelId, String channelName, int weeks) { + channelVoteId.remove(channelId); + + String shareUrl = createVote(channelId, channelName, weeks); + System.out.println("[Discord When:D] Vote recreated for channel " + channelId + " (weeks=" + weeks + ")"); + return shareUrl; + } + + @Override + public String getShareUrl(String channelId) { + return channelShareUrl.get(channelId); + } + + @Override + public String getVoteDeadline(String channelId) { + LocalDateTime createdAt = voteCreatedAt.get(channelId); + if (createdAt == null) { + return "No vote created."; + } + LocalDateTime deadline = createdAt.plusHours(24); + + return deadline.format(DateTimeFormatter.ofPattern("HH:mm")); + } + + @Override + public String getTopRankedDateTime(String channelId) { + Long voteId = channelVoteId.get(channelId); + if (voteId == null) { + return "1순위 일정"; + } + + VoteResultRes res = voteResultService.getVoteResult(voteId); + if (res == null || res.rankings() == null || res.rankings().isEmpty()) { + return "1순위 일정"; + } + + RankingRes top = res.rankings().stream() + .filter(r -> r.rank() != null && r.rank() == 1) + .findFirst() + .orElse(res.rankings().get(0)); + + LocalDate date = top.date(); + String period = top.period(); + + String dayLabel = switch (date.getDayOfWeek()) { + case MONDAY -> "월"; + case TUESDAY -> "화"; + case WEDNESDAY -> "수"; + case THURSDAY -> "목"; + case FRIDAY -> "금"; + case SATURDAY -> "토"; + case SUNDAY -> "일"; + }; + + String periodLabel = "LUNCH".equals(period) ? "점심" : "저녁"; + + return date.format(java.time.format.DateTimeFormatter.ofPattern("MM/dd")) + + "(" + dayLabel + ") " + + periodLabel; + } + + @Override + public String getChannelIdByVoteId(Long voteId) { + for (Map.Entry entry : channelVoteId.entrySet()) { + if (entry.getValue().equals(voteId)) { + return entry.getKey(); + } + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/com/workingdead/chatbot/kakao/controller/KakaoSkillController.java b/src/main/java/com/workingdead/chatbot/kakao/controller/KakaoSkillController.java new file mode 100644 index 0000000..5a48424 --- /dev/null +++ b/src/main/java/com/workingdead/chatbot/kakao/controller/KakaoSkillController.java @@ -0,0 +1,191 @@ +package com.workingdead.chatbot.kakao.controller; + +import com.workingdead.chatbot.kakao.dto.KakaoRequest; +import com.workingdead.chatbot.kakao.dto.KakaoResponse; +import com.workingdead.chatbot.kakao.scheduler.KakaoWendyScheduler; +import com.workingdead.chatbot.kakao.service.KakaoWendyService; +import com.workingdead.chatbot.kakao.service.KakaoWendyService.SessionState; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * 카카오 i 오픈빌더 스킬 서버 컨트롤러 + * + * 카카오톡 챗봇에서 발화를 받아 처리하고 응답을 반환합니다. + */ +@Tag(name = "Kakao Chatbot", description = "카카오 챗봇 스킬 API") +@RestController +@RequestMapping("/kakao/skill") +@RequiredArgsConstructor +@Slf4j +public class KakaoSkillController { + + private final KakaoWendyService kakaoWendyService; + private final KakaoWendyScheduler kakaoWendyScheduler; + + /** + * 메인 스킬 엔드포인트 (폴백 블록) + * 모든 발화를 여기서 처리 + */ + @Operation(summary = "메인 스킬 (폴백)") + @PostMapping("/main") + public ResponseEntity handleMain(@RequestBody KakaoRequest request) { + String userKey = request.getUserKey(); + String utterance = request.getUtterance(); + + log.info("[Kakao Skill] userKey={}, utterance={}", userKey, utterance); + + if (utterance == null || utterance.isBlank()) { + return ResponseEntity.ok(kakaoWendyService.help()); + } + + String trimmed = utterance.trim(); + + // 1. 웬디 시작 + if (trimmed.equals("웬디 시작") || trimmed.equals("시작")) { + return ResponseEntity.ok(kakaoWendyService.startSession(userKey)); + } + + // 2. 도움말 + if (trimmed.equals("웬디 도움말") || trimmed.equals("도움말") || trimmed.equals("/help")) { + return ResponseEntity.ok(kakaoWendyService.help()); + } + + // 3. 웬디 종료 + if (trimmed.equals("웬디 종료") || trimmed.equals("종료")) { + kakaoWendyScheduler.stopSchedule(userKey); + return ResponseEntity.ok(kakaoWendyService.endSession(userKey)); + } + + // 4. 웬디 결과 + if (trimmed.equals("웬디 결과") || trimmed.equals("결과") || trimmed.equals("결과 확인")) { + return ResponseEntity.ok(kakaoWendyService.getVoteResult(userKey)); + } + + // 5. 웬디 재투표 + if (trimmed.equals("웬디 재투표") || trimmed.equals("재투표")) { + return ResponseEntity.ok(kakaoWendyService.revote(userKey)); + } + + // 세션 상태에 따른 처리 + SessionState state = kakaoWendyService.getSessionState(userKey); + + switch (state) { + case WAITING_PARTICIPANTS: + // 참석자 이름 입력 + return ResponseEntity.ok(kakaoWendyService.addParticipants(userKey, trimmed)); + + case WAITING_WEEKS: + // 주차 선택 + Integer weeks = kakaoWendyService.parseWeeks(trimmed); + if (weeks != null) { + KakaoResponse response = kakaoWendyService.createVote(userKey, weeks); + kakaoWendyScheduler.startSchedule(userKey); + return ResponseEntity.ok(response); + } + break; + + default: + break; + } + + // 알 수 없는 입력 + return ResponseEntity.ok(kakaoWendyService.unknownInput(userKey)); + } + + /** + * 웬디 시작 스킬 (전용 블록) + */ + @Operation(summary = "웬디 시작") + @PostMapping("/start") + public ResponseEntity handleStart(@RequestBody KakaoRequest request) { + String userKey = request.getUserKey(); + log.info("[Kakao Skill] START - userKey={}", userKey); + return ResponseEntity.ok(kakaoWendyService.startSession(userKey)); + } + + /** + * 웬디 종료 스킬 (전용 블록) + */ + @Operation(summary = "웬디 종료") + @PostMapping("/end") + public ResponseEntity handleEnd(@RequestBody KakaoRequest request) { + String userKey = request.getUserKey(); + log.info("[Kakao Skill] END - userKey={}", userKey); + kakaoWendyScheduler.stopSchedule(userKey); + return ResponseEntity.ok(kakaoWendyService.endSession(userKey)); + } + + /** + * 주차 선택 스킬 (전용 블록) + * params에서 weeks 값을 받음 + */ + @Operation(summary = "주차 선택") + @PostMapping("/select-week") + public ResponseEntity handleSelectWeek(@RequestBody KakaoRequest request) { + String userKey = request.getUserKey(); + String weeksParam = request.getParam("weeks"); + + log.info("[Kakao Skill] SELECT_WEEK - userKey={}, weeks={}", userKey, weeksParam); + + int weeks = 0; + if (weeksParam != null) { + try { + weeks = Integer.parseInt(weeksParam); + } catch (NumberFormatException e) { + Integer parsed = kakaoWendyService.parseWeeks(weeksParam); + if (parsed != null) weeks = parsed; + } + } + + KakaoResponse response = kakaoWendyService.createVote(userKey, weeks); + kakaoWendyScheduler.startSchedule(userKey); + return ResponseEntity.ok(response); + } + + /** + * 결과 조회 스킬 (전용 블록) + */ + @Operation(summary = "투표 결과 조회") + @PostMapping("/result") + public ResponseEntity handleResult(@RequestBody KakaoRequest request) { + String userKey = request.getUserKey(); + log.info("[Kakao Skill] RESULT - userKey={}", userKey); + return ResponseEntity.ok(kakaoWendyService.getVoteResult(userKey)); + } + + /** + * 재투표 스킬 (전용 블록) + */ + @Operation(summary = "재투표") + @PostMapping("/revote") + public ResponseEntity handleRevote(@RequestBody KakaoRequest request) { + String userKey = request.getUserKey(); + log.info("[Kakao Skill] REVOTE - userKey={}", userKey); + kakaoWendyScheduler.stopSchedule(userKey); + return ResponseEntity.ok(kakaoWendyService.revote(userKey)); + } + + /** + * 도움말 스킬 (전용 블록) + */ + @Operation(summary = "도움말") + @PostMapping("/help") + public ResponseEntity handleHelp(@RequestBody KakaoRequest request) { + log.info("[Kakao Skill] HELP - userKey={}", request.getUserKey()); + return ResponseEntity.ok(kakaoWendyService.help()); + } + + /** + * 헬스체크 (카카오 스킬 서버 상태 확인용) + */ + @Operation(summary = "헬스체크") + @GetMapping("/health") + public ResponseEntity health() { + return ResponseEntity.ok("OK"); + } +} \ No newline at end of file diff --git a/src/main/java/com/workingdead/chatbot/kakao/dto/KakaoRequest.java b/src/main/java/com/workingdead/chatbot/kakao/dto/KakaoRequest.java new file mode 100644 index 0000000..8550290 --- /dev/null +++ b/src/main/java/com/workingdead/chatbot/kakao/dto/KakaoRequest.java @@ -0,0 +1,124 @@ +package com.workingdead.chatbot.kakao.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.Map; + +/** + * 카카오 i 오픈빌더 스킬 요청 DTO + */ +@Getter +@Setter +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class KakaoRequest { + + private Intent intent; + private UserRequest userRequest; + private Bot bot; + private Action action; + + @Getter + @Setter + @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Intent { + private String id; + private String name; + } + + @Getter + @Setter + @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class UserRequest { + private String timezone; + private Map params; + private Block block; + private String utterance; + private String lang; + private User user; + } + + @Getter + @Setter + @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Block { + private String id; + private String name; + } + + @Getter + @Setter + @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class User { + private String id; + private String type; + private Properties properties; + } + + @Getter + @Setter + @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Properties { + private String plusfriendUserKey; + private String appUserId; + private Boolean isFriend; + } + + @Getter + @Setter + @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Bot { + private String id; + private String name; + } + + @Getter + @Setter + @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Action { + private String name; + private String id; + private Map params; + private Map detailParams; + private Map clientExtra; + } + + // 편의 메서드 + public String getUserKey() { + if (userRequest != null && userRequest.getUser() != null) { + return userRequest.getUser().getId(); + } + return null; + } + + public String getUtterance() { + if (userRequest != null) { + return userRequest.getUtterance(); + } + return null; + } + + public String getParam(String key) { + if (action != null && action.getParams() != null) { + return action.getParams().get(key); + } + return null; + } + + public String getBotId() { + if (bot != null) { + return bot.getId(); + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/com/workingdead/chatbot/kakao/dto/KakaoResponse.java b/src/main/java/com/workingdead/chatbot/kakao/dto/KakaoResponse.java new file mode 100644 index 0000000..3fdb596 --- /dev/null +++ b/src/main/java/com/workingdead/chatbot/kakao/dto/KakaoResponse.java @@ -0,0 +1,274 @@ +package com.workingdead.chatbot.kakao.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * 카카오 i 오픈빌더 스킬 응답 DTO + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class KakaoResponse { + + private String version; + private Template template; + private Map context; + private Map data; + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Template { + private List outputs; + private List quickReplies; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Output { + private SimpleText simpleText; + private SimpleImage simpleImage; + private BasicCard basicCard; + private ListCard listCard; + private Carousel carousel; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class SimpleText { + private String text; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class SimpleImage { + private String imageUrl; + private String altText; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class BasicCard { + private String title; + private String description; + private Thumbnail thumbnail; + private List