diff --git a/.env b/.env deleted file mode 100644 index c7da042..0000000 --- a/.env +++ /dev/null @@ -1 +0,0 @@ -DB_PASSWORD=workingdead \ No newline at end of file diff --git a/.gitignore b/.gitignore index 32d9108..fa1f670 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ HELP.md .env +dump.rdb .gradle build/ !gradle/wrapper/gradle-wrapper.jar diff --git a/build.gradle b/build.gradle index 5ba34e1..c19f7c5 100644 --- a/build.gradle +++ b/build.gradle @@ -34,8 +34,8 @@ dependencies { implementation 'org.flywaydb:flyway-core' implementation 'org.flywaydb:flyway-database-postgresql' implementation 'org.springframework.session:spring-session-jdbc' - implementation 'org.springframework.boot:spring-boot-starter-data-redis' - implementation 'org.springframework.boot:spring-boot-starter-data-redis-reactive' +// implementation 'org.springframework.boot:spring-boot-starter-data-redis' +// implementation 'org.springframework.boot:spring-boot-starter-data-redis-reactive' implementation 'org.apache.commons:commons-pool2' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.h2database:h2' diff --git a/codebase-analysis.md b/codebase-analysis.md new file mode 100644 index 0000000..1ec17cb --- /dev/null +++ b/codebase-analysis.md @@ -0,0 +1,535 @@ +# WorkingDead Backend - Codebase 분석 + +## 프로젝트 개요 + +**When:D (웬디)** - 멀티 플랫폼 일정 조율 서비스 + +팀/그룹의 회식, 모임 일정을 조율하기 위한 투표 시스템을 제공하며, **Discord 봇**과 **카카오톡 챗봇** 두 플랫폼을 통해 자동화된 투표 생성 및 알림 기능을 제공합니다. + +--- + +## 기술 스택 + +| 구분 | 기술 | +|------|------| +| **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 | +| **Kakao Chatbot** | Kakao i Open Builder (REST API) | +| **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 설정 +│ └── KakaoConfig.java # Kakao API 설정 +│ +├── 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/ # Discord 봇 + │ ├── command/ + │ │ └── DiscordWendyCommand.java # 디스코드 명령어 핸들러 + │ ├── service/ + │ │ ├── DiscordWendyService.java # 봇 서비스 인터페이스 + │ │ ├── DiscordWendyServiceImpl.java # 봇 서비스 구현체 + │ │ └── DiscordWendyNotifier.java # 알림 서비스 + │ ├── scheduler/ + │ │ └── DiscordWendyScheduler.java # 스케줄러 (리마인드) + │ └── dto/ + │ └── DiscordVoteResult.java # 투표 결과 DTO + │ + └── kakao/ # Kakao 챗봇 + ├── controller/ + │ └── KakaoSkillController.java # Skill Server 엔드포인트 + ├── service/ + │ ├── KakaoWendyService.java # 세션 관리 서비스 + │ └── KakaoNotifier.java # 알림 서비스 + ├── scheduler/ + │ └── KakaoWendyScheduler.java # 스케줄러 (리마인드) + └── dto/ + ├── KakaoRequest.java # Skill 요청 DTO + └── KakaoResponse.java # Skill 응답 DTO +``` + +--- + +## 핵심 도메인 모델 + +### 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}` | 우선순위 설정 | + +### Kakao Skill API (`/kakao/skill`) + +| Method | Endpoint | 설명 | +|--------|----------|------| +| POST | `/kakao/skill/start` | 웬디 시작 (세션 생성) | +| POST | `/kakao/skill/participants` | 참석자 추가 | +| POST | `/kakao/skill/weeks` | 주차 선택 및 투표 생성 | +| POST | `/kakao/skill/revote` | 재투표 | +| POST | `/kakao/skill/end` | 세션 종료 | +| POST | `/kakao/skill/status` | 현재 상태 조회 | +| POST | `/kakao/skill/help` | 도움말 | + +--- + +## 멀티 플랫폼 아키텍처 + +### 플랫폼별 특성 비교 + +| 구분 | Discord | Kakao | +|------|---------|-------| +| **통신 방식** | WebSocket (JDA) | REST API (Skill Server) | +| **세션 식별자** | channelId | userKey | +| **알림 방식** | Push (봇이 직접 전송) | Pull (사용자 요청 시 응답) | +| **멀티 유저** | 채널 내 다수 참여 | 개인 챗 기반 | +| **이벤트 처리** | ListenerAdapter | REST Controller | + +### 아키텍처 다이어그램 + +``` + ┌─────────────────────────────────────────┐ + │ Core Services (meet/) │ + │ ┌─────────────┐ ┌─────────────────┐ │ + │ │ VoteService │ │ ParticipantSvc │ │ + │ └─────────────┘ └─────────────────┘ │ + │ ┌─────────────┐ ┌─────────────────┐ │ + │ │VoteResultSvc│ │ PriorityService│ │ + │ └─────────────┘ └─────────────────┘ │ + └─────────────────────────────────────────┘ + ▲ + │ + ┌────────────────────────┼────────────────────────┐ + │ │ │ + ▼ │ ▼ +┌──────────────────────────┐ │ ┌──────────────────────────┐ +│ Discord Module │ │ │ Kakao Module │ +├──────────────────────────┤ │ ├──────────────────────────┤ +│ DiscordWendyCommand │ │ │ KakaoSkillController │ +│ (ListenerAdapter) │ │ │ (REST Controller) │ +├──────────────────────────┤ │ ├──────────────────────────┤ +│ DiscordWendyService │ │ │ KakaoWendyService │ +│ (channelId 기반 세션) │ │ │ (userKey 기반 세션) │ +├──────────────────────────┤ │ ├──────────────────────────┤ +│ DiscordWendyScheduler │ │ │ KakaoWendyScheduler │ +│ DiscordWendyNotifier │ │ │ KakaoNotifier │ +└──────────────────────────┘ │ └──────────────────────────┘ + │ │ │ + ▼ │ ▼ + Discord Server │ Kakao Talk + (WebSocket) │ (REST API) +``` + +--- + +## Discord Bot (웬디) + +### 아키텍처 + +``` +┌───────────────────┐ ┌───────────────────┐ ┌─────────────────┐ +│DiscordWendyCommand│────▶│DiscordWendyService│────▶│ VoteService │ +│ (ListenerAdapter │ │ Impl │ │ Participant │ +│ 이벤트 핸들러) │ │ (세션 관리) │ │ Service │ +└───────────────────┘ └───────────────────┘ └─────────────────┘ + │ │ + ▼ ▼ +┌───────────────────┐ ┌───────────────────┐ +│DiscordWendyScheduler────▶│DiscordWendyNotifier│ +│ (시간 기반 │ │ (메시지 전송) │ +│ 태스크 관리) │ └───────────────────┘ +└───────────────────┘ +``` + +### 명령어 + +| 명령어 | 설명 | +|--------|------| +| `웬디 시작` | 일정 조율 세션 시작 | +| `웬디 종료` | 세션 종료 | +| `웬디 재투표` | 동일 참석자로 새 투표 생성 | +| `웬디 도움말` / `/help` | 도움말 표시 | + +### 알림 스케줄 + +| 시간 | 알림 내용 | +|------|----------| +| 10분 후 | 투표 현황 공유 | +| 15분 후 | 미투표자 독촉 (1차) | +| 1시간 후 | 미투표자 독촉 (2차) | +| 6시간 후 | 미투표자 독촉 (3차) | +| 12시간 후 | 미투표자 독촉 (4차) | +| 24시간 후 | 최후통첩 (1순위로 확정 안내) | + +### 세션 관리 (DiscordWendyServiceImpl) + +```java +// 채널별 상태 관리 +private final Set activeSessions; // 활성 세션 +private final Map> participants; // 참석자 +private final Map channelVoteId; // 채널 -> 투표ID +private final Map channelShareUrl; // 채널 -> 공유URL +``` + +--- + +## Kakao Chatbot (웬디) + +### 아키텍처 + +``` +┌───────────────────┐ ┌───────────────────┐ ┌─────────────────┐ +│KakaoSkillController────▶│ KakaoWendyService │────▶│ VoteService │ +│ (REST API │ │ (세션 관리) │ │ Participant │ +│ Skill Server) │ │ │ │ Service │ +└───────────────────┘ └───────────────────┘ └─────────────────┘ + │ │ + ▼ ▼ +┌───────────────────┐ ┌───────────────────┐ +│KakaoWendyScheduler│────▶│ KakaoNotifier │ +│ (시간 기반 │ │ (알림 전송) │ +│ 태스크 관리) │ └───────────────────┘ +└───────────────────┘ +``` + +### 세션 상태 (SessionState) + +```java +public enum SessionState { + IDLE, // 대기 상태 + WAITING_PARTICIPANTS, // 참석자 입력 대기 + WAITING_WEEKS, // 주차 선택 대기 + VOTE_CREATED // 투표 생성 완료 +} +``` + +### 세션 관리 (KakaoWendyService) + +```java +// userKey 기반 상태 관리 +private final Map sessionStates; // 세션 상태 +private final Map> participants; // 참석자 +private final Map userVoteId; // 투표 ID +private final Map userShareUrl; // 공유 URL +private final Map userVoteName; // 투표 이름 +``` + +### Kakao i Open Builder 연동 + +- **요청 DTO** (KakaoRequest): userRequest, action, intent, contexts 포함 +- **응답 DTO** (KakaoResponse): simpleText, textWithQuickReplies, basicCard 등 다양한 응답 형식 지원 + +--- + +## 투표 결과 집계 로직 + +### 정렬 기준 (VoteResultService) + +``` +1순위: 투표 인원수 (많을수록 상위) +2순위: 우선순위 Index 합계 (작을수록 상위) +3순위: 날짜 (빠를수록 상위) +``` + +### 예시 + +| 날짜 | 시간대 | 인원 | 우선순위합 | 순위 | +|------|--------|------|------------|------| +| 01/20 | LUNCH | 5명 | 3 | 1위 | +| 01/21 | DINNER | 5명 | 7 | 2위 | +| 01/19 | LUNCH | 4명 | 2 | 3위 | + +--- + +## 설정 파일 + +### 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} + +kakao: + rest-api-key: ${KAKAO_REST_API_KEY} + admin-key: ${KAKAO_ADMIN_KEY} + channel-id: ${KAKAO_CHANNEL_ID} +``` + +### 환경 변수 + +| 변수명 | 설명 | +|--------|------| +| `DB_PASSWORD` | PostgreSQL 비밀번호 | +| `DISCORD_TOKEN` | Discord Bot 토큰 | +| `KAKAO_REST_API_KEY` | Kakao REST API 키 | +| `KAKAO_ADMIN_KEY` | Kakao Admin 키 | +| `KAKAO_CHANNEL_ID` | Kakao 채널 ID | +| `AWS_ACCESS_KEY_ID` | AWS 액세스 키 | +| `AWS_SECRET_ACCESS_KEY` | AWS 시크릿 키 | + +--- + +## 보안 설정 + +### 현재 상태 + +```java +// SecurityConfig.java +.authorizeHttpRequests(auth -> auth + .requestMatchers("/v3/api-docs/**", "/swagger-ui/**").permitAll() + .requestMatchers("/kakao/skill/**").permitAll() // Kakao Skill Server + .anyRequest().permitAll() // 모든 요청 허용 +); +``` + +- **인증**: 미구현 (모든 API 공개) +- **CSRF**: 비활성화 +- **CORS**: 지정된 도메인만 허용 + +### 허용 도메인 (CorsConfig) + +- localhost:3000, 5173, 8080, 8081 +- whend.app (HTTP/HTTPS) +- whendy.netlify.app + +--- + +## 핵심 비즈니스 플로우 + +### 1. Discord 투표 생성 플로우 + +``` +1. 디스코드에서 "웬디 시작" 입력 +2. 참석자 선택 (드롭다운 메뉴) +3. 주차 선택 (이번 주 ~ 6주 뒤) +4. Vote 엔티티 생성 + Participant 일괄 생성 +5. 공유 URL 반환 (whendy.netlify.app/v/{code}) +6. 스케줄러 시작 (리마인드 알림) +``` + +### 2. Kakao 투표 생성 플로우 + +``` +1. 카카오톡에서 "웬디 시작" 발화 +2. 참석자 이름 입력 (쉼표 구분) +3. 주차 선택 (Quick Reply 버튼) +4. Vote 엔티티 생성 + Participant 일괄 생성 +5. 공유 URL 반환 +6. 스케줄러 시작 (리마인드 알림) +``` + +### 3. 투표 참여 플로우 (공통) + +``` +1. 공유 URL 접속 +2. 참여자 칩 선택 (본인 선택) +3. 날짜/시간대 선택 (LUNCH/DINNER) +4. 우선순위 설정 (1~3순위, 선택사항) +5. 제출 → ParticipantSelection, PriorityPreference 저장 +``` + +### 4. 결과 조회 플로우 + +``` +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 + Kakao 동시 운영 +2. **플랫폼 독립적 코어**: 핵심 투표 로직은 공유, 플랫폼별 어댑터 분리 +3. **실시간 알림**: 스케줄러 기반 자동 리마인드 (플랫폼별 구현) +4. **우선순위 시스템**: 단순 투표가 아닌 가중치 기반 결과 도출 +5. **공유 URL**: 8자리 고유 코드로 간편한 공유 +6. **세션 관리**: 플랫폼별 독립적인 세션 상태 관리 + - Discord: channelId 기반 + - Kakao: userKey 기반 + +--- + +*문서 생성일: 2026-01-19* +*최종 업데이트: 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/src/main/java/com/workingdead/chatbot/command/WendyCommand.java b/src/main/java/com/workingdead/chatbot/discord/command/DiscordWendyCommand.java similarity index 66% rename from src/main/java/com/workingdead/chatbot/command/WendyCommand.java rename to src/main/java/com/workingdead/chatbot/discord/command/DiscordWendyCommand.java index f651997..f72b5ed 100644 --- a/src/main/java/com/workingdead/chatbot/command/WendyCommand.java +++ b/src/main/java/com/workingdead/chatbot/discord/command/DiscordWendyCommand.java @@ -1,41 +1,36 @@ -package com.workingdead.chatbot.command; +package com.workingdead.chatbot.discord.command; -import com.workingdead.chatbot.scheduler.WendyScheduler; -import com.workingdead.chatbot.service.WendyService; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; +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.entities.emoji.Emoji; 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.events.message.react.MessageReactionAddEvent; 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; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - @Component -public class WendyCommand extends ListenerAdapter { - - private final WendyService wendyService; - private final WendyScheduler wendyScheduler; - +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 WendyCommand(WendyService wendyService, WendyScheduler wendyScheduler) { - this.wendyService = wendyService; - this.wendyScheduler = wendyScheduler; + + public DiscordWendyCommand(DiscordWendyService discordWendyService, DiscordWendyScheduler discordWendyScheduler) { + this.discordWendyService = discordWendyService; + this.discordWendyScheduler = discordWendyScheduler; } @Override @@ -52,45 +47,36 @@ public void onGuildJoin(net.dv8tion.jda.api.events.guild.GuildJoinEvent event) { @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(); - - // 1.1 웬디 시작 + + // 웬디 시작 if (content.equals("웬디 시작")) { handleStart(channel); return; } - - // 4.1 도움말 + + // 도움말 if (content.equals("/help") || content.equals("웬디 도움말")) { handleHelp(channel); return; } - + // 세션 체크 - if (!wendyService.isSessionActive(channelId)) { + if (!discordWendyService.isSessionActive(channelId)) { return; } - -// // 2.1~2.2 날짜 범위 입력 -// if (waitingForDateInput.getOrDefault(channelId, false)) { -// Integer weeks = extractWeeks(content); -// if (weeks != null) { -// handleDateInput(channel, member, weeks, false); -// return; -// } -// } - - // 4.2 재투표 + + // 재투표 if (content.equals("웬디 재투표")) { handleRevote(channel); return; } - - // 3.1 웬디 종료 + + // 웬디 종료 if (content.equals("웬디 종료")) { handleEnd(channel); return; @@ -104,17 +90,16 @@ public void onEntitySelectInteraction(EntitySelectInteractionEvent event) { } String channelId = event.getChannel().getId(); - if (!wendyService.isSessionActive(channelId)) { + if (!discordWendyService.isSessionActive(channelId)) { return; } event.getMentions().getMembers().forEach(member -> { - wendyService.addParticipant(channelId, member.getId(), member.getEffectiveName()); - System.out.println("[Command] Participant added via select menu: " + member.getEffectiveName()); + 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 @@ -125,11 +110,10 @@ public void onStringSelectInteraction(StringSelectInteractionEvent event) { } String channelId = event.getChannel().getId(); - if (!wendyService.isSessionActive(channelId)) { + if (!discordWendyService.isSessionActive(channelId)) { return; } - // 하나만 선택하게 설정할 예정이므로 첫 번째 값만 사용 String value = event.getValues().get(0); int weeks; try { @@ -151,43 +135,17 @@ public void onStringSelectInteraction(StringSelectInteractionEvent event) { event.reply("투표 날짜 범위를 선택하셨어요!").setEphemeral(true).queue(); } -// @Override -// public void onMessageReactionAdd(MessageReactionAddEvent event) { -// if (event.getUser() != null && event.getUser().isBot()) return; -// -// String channelId = event.getChannel().getId(); -// String messageId = event.getMessageId(); -// -// String checkMessageId = participantCheckMessages.get(channelId); -// if (checkMessageId == null || !checkMessageId.equals(messageId)) { -// return; -// } -// -// if (!event.getReaction().getEmoji().equals(Emoji.fromUnicode("✅"))) { -// return; -// } -// -// event.retrieveMember().queue(member -> { -// if (member != null) { -// wendyService.addParticipant(channelId, member.getId(), member.getEffectiveName()); -// System.out.println("[Command] Participant added: " + member.getEffectiveName()); -// } -// }); -// } - private void handleStart(TextChannel channel) { String channelId = channel.getId(); List members = channel.getMembers(); - - wendyService.startSession(channelId, members); - + + discordWendyService.startSession(channelId, members); + channel.sendMessage(""" 안녕하세요! 일정 조율 도우미 웬디에요 :D 지금부터 여러분의 일정 조율을 도와드릴게요 """).queue(); - - // 참석자 입력용 엔티티 셀렉트 메뉴 (유저 선택 드롭다운) EntitySelectMenu attendeeMenu = EntitySelectMenu.create(ATTENDEE_SELECT_MENU_ID, EntitySelectMenu.SelectTarget.USER) .setPlaceholder("참석자를 선택 / 검색해 주세요.") .setRequiredRange(1, 25) @@ -197,7 +155,6 @@ private void handleStart(TextChannel channel) { .setActionRow(attendeeMenu) .queue(); - // 2.1 날짜 범위 파악 질문 (드롭다운 방식) StringSelectMenu weekMenu = StringSelectMenu.create(WEEK_SELECT_MENU_ID) .setPlaceholder("몇 주 뒤의 일정을 계획하시나요?") .addOption("이번 주", "0") @@ -213,54 +170,36 @@ private void handleStart(TextChannel channel) { .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 - ? wendyService.recreateVote(channelId, channelName, weeks) - : wendyService.createVote(channelId, channelName, weeks); - + + String voteUrl = isRevote + ? discordWendyService.recreateVote(channelId, channelName, weeks) + : discordWendyService.createVote(channelId, channelName, weeks); + channel.sendMessage(voteUrl).queue(); - wendyScheduler.startSchedule(channel); - - -// // 투표 제한시간(24시간) + 30분 후 자동 종료 스케줄 -// CompletableFuture -// .delayedExecutor(3 * 60 + 30, TimeUnit.SECONDS) -// .execute(() -> { -// String chId = channel.getId(); -// // 스케줄러 정리 + 세션 종료 -// wendyScheduler.stopSchedule(chId); -// wendyService.endSession(chId); -// -// // 안내 메시지 전송 -// channel.sendMessage(""" -// 투표 제한 시간이 지나 웬디가 자동으로 종료되었어요 :D -// 다시 일정 조율이 필요하시면 '웬디 시작'을 입력해 주세요! -// """).queue(); -// }); + discordWendyScheduler.startSchedule(channel); } - + private void handleRevote(TextChannel channel) { String channelId = channel.getId(); - - if (!wendyService.hasPreviousVote(channelId)) { + + if (!discordWendyService.hasPreviousVote(channelId)) { channel.sendMessage("아직 진행된 투표가 없어요🗑️").queue(); return; } - - wendyScheduler.stopSchedule(channelId); + discordWendyScheduler.stopSchedule(channelId); StringSelectMenu weekMenu = StringSelectMenu.create(WEEK_SELECT_MENU_REVOTE_ID) .setPlaceholder("몇 주 뒤의 일정을 다시 계획하시나요?") @@ -277,33 +216,33 @@ private void handleRevote(TextChannel channel) { .setActionRow(weekMenu) .queue(); } - + private void handleEnd(TextChannel channel) { String channelId = channel.getId(); - - wendyScheduler.stopSchedule(channelId); - wendyService.endSession(channelId); - + + discordWendyScheduler.stopSchedule(channelId); + discordWendyService.endSession(channelId); + participantCheckMessages.remove(channelId); waitingForDateInput.remove(channelId); - + channel.sendMessage(""" 웬디는 여기서 눈치껏 빠질게요 :D 모두 알찬 시간 보내세요! """).queue(); - System.out.println("[Command] Session ended: " + channelId); + 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; diff --git a/src/main/java/com/workingdead/chatbot/dto/VoteResult.java b/src/main/java/com/workingdead/chatbot/discord/dto/DiscordVoteResult.java similarity index 86% rename from src/main/java/com/workingdead/chatbot/dto/VoteResult.java rename to src/main/java/com/workingdead/chatbot/discord/dto/DiscordVoteResult.java index 3ad8955..07ab882 100644 --- a/src/main/java/com/workingdead/chatbot/dto/VoteResult.java +++ b/src/main/java/com/workingdead/chatbot/discord/dto/DiscordVoteResult.java @@ -1,57 +1,57 @@ -package com.workingdead.chatbot.dto; +package com.workingdead.chatbot.discord.dto; import java.util.List; -public class VoteResult { +public class DiscordVoteResult { private String voteUrl; private List rankings; - - public VoteResult() {} - - public VoteResult(String voteUrl, 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; diff --git a/src/main/java/com/workingdead/chatbot/scheduler/WendyScheduler.java b/src/main/java/com/workingdead/chatbot/discord/scheduler/DiscordWendyScheduler.java similarity index 72% rename from src/main/java/com/workingdead/chatbot/scheduler/WendyScheduler.java rename to src/main/java/com/workingdead/chatbot/discord/scheduler/DiscordWendyScheduler.java index ad538da..959de56 100644 --- a/src/main/java/com/workingdead/chatbot/scheduler/WendyScheduler.java +++ b/src/main/java/com/workingdead/chatbot/discord/scheduler/DiscordWendyScheduler.java @@ -1,7 +1,7 @@ -package com.workingdead.chatbot.scheduler; +package com.workingdead.chatbot.discord.scheduler; -import com.workingdead.chatbot.service.WendyNotifier; -import com.workingdead.chatbot.service.WendyNotifier.RemindTiming; +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; @@ -10,41 +10,41 @@ import java.util.concurrent.*; @Component -public class WendyScheduler { - +public class DiscordWendyScheduler { + private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2); - private final WendyNotifier notifier; + private final DiscordWendyNotifier notifier; private final Map>> channelTasks = new ConcurrentHashMap<>(); - - public WendyScheduler(WendyNotifier notifier) { + + public DiscordWendyScheduler(DiscordWendyNotifier notifier) { this.notifier = notifier; } - + public void startSchedule(TextChannel channel) { String channelId = channel.getId(); stopSchedule(channelId); - + CopyOnWriteArrayList> tasks = new CopyOnWriteArrayList<>(); - - // 2.3 투표 현황: 10분 후 첫 공유 + + // 투표 현황: 10분 후 첫 공유 tasks.add(scheduler.schedule(() -> notifier.shareVoteStatus(channel), 10, TimeUnit.MINUTES)); - // 2.4 미투표자 독촉 + // 미투표자 독촉 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("[Scheduler] Schedule started: " + channelId); + 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("[Scheduler] Schedule stopped: " + channelId); + System.out.println("[Discord Scheduler] Schedule stopped: " + channelId); } } } \ No newline at end of file diff --git a/src/main/java/com/workingdead/chatbot/service/WendyNotifier.java b/src/main/java/com/workingdead/chatbot/discord/service/DiscordWendyNotifier.java similarity index 79% rename from src/main/java/com/workingdead/chatbot/service/WendyNotifier.java rename to src/main/java/com/workingdead/chatbot/discord/service/DiscordWendyNotifier.java index 1af2e9d..f70bb62 100644 --- a/src/main/java/com/workingdead/chatbot/service/WendyNotifier.java +++ b/src/main/java/com/workingdead/chatbot/discord/service/DiscordWendyNotifier.java @@ -1,31 +1,30 @@ -package com.workingdead.chatbot.service; +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.JDA; import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor -public class WendyNotifier { +public class DiscordWendyNotifier { - private final WendyService wendyService; + private final DiscordWendyService discordWendyService; public void shareVoteStatus(TextChannel channel) { try { - VoteResultRes result = wendyService.getVoteStatus(channel.getId()); - String shareUrl = wendyService.getShareUrl(channel.getId()); + 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; @@ -56,19 +55,17 @@ public void shareVoteStatus(TextChannel channel) { sb.append("투표자: ").append(voterStr).append("\n"); } sb.append("\n"); - - } channel.sendMessage(sb.toString()).queue(); } catch (Exception e) { - System.err.println("[Scheduler] Failed to share vote status: " + e.getMessage()); + System.err.println("[Discord Scheduler] Failed to share vote status: " + e.getMessage()); } } public void remindNonVoters(TextChannel channel, RemindTiming timing) { try { - List nonVoterIds = wendyService.getNonVoterIds(channel.getId()); + List nonVoterIds = discordWendyService.getNonVoterIds(channel.getId()); if (nonVoterIds == null || nonVoterIds.isEmpty()) { return; @@ -83,19 +80,18 @@ public void remindNonVoters(TextChannel channel, RemindTiming timing) { case HOUR_6 -> "다들 " + mentions + " 님의 투표를 기다리고 있어요🙌"; case HOUR_12 -> mentions + " 웬디 기다리다 지쳐버림…🥹 대머리신가요?"; case HOUR_24 -> { - String bestDateTime = wendyService.getTopRankedDateTime(channel.getId()); - String deadline = wendyService.getVoteDeadline(channel.getId()); + 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("[Scheduler] Reminder sent: " + timing); + System.out.println("[Discord Scheduler] Reminder sent: " + timing); } catch (Exception e) { - System.err.println("[Scheduler] Failed to send reminder: " + e.getMessage()); + 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/service/WendyService.java b/src/main/java/com/workingdead/chatbot/discord/service/DiscordWendyService.java similarity index 91% rename from src/main/java/com/workingdead/chatbot/service/WendyService.java rename to src/main/java/com/workingdead/chatbot/discord/service/DiscordWendyService.java index 5f54b76..6775517 100644 --- a/src/main/java/com/workingdead/chatbot/service/WendyService.java +++ b/src/main/java/com/workingdead/chatbot/discord/service/DiscordWendyService.java @@ -1,18 +1,18 @@ -package com.workingdead.chatbot.service; +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 WendyService { +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); diff --git a/src/main/java/com/workingdead/chatbot/service/WendyServiceImpl.java b/src/main/java/com/workingdead/chatbot/discord/service/DiscordWendyServiceImpl.java similarity index 80% rename from src/main/java/com/workingdead/chatbot/service/WendyServiceImpl.java rename to src/main/java/com/workingdead/chatbot/discord/service/DiscordWendyServiceImpl.java index 5250567..16a2e50 100644 --- a/src/main/java/com/workingdead/chatbot/service/WendyServiceImpl.java +++ b/src/main/java/com/workingdead/chatbot/discord/service/DiscordWendyServiceImpl.java @@ -1,4 +1,4 @@ -package com.workingdead.chatbot.service; +package com.workingdead.chatbot.discord.service; import com.workingdead.meet.dto.ParticipantDtos.ParticipantRes; import com.workingdead.meet.dto.ParticipantDtos.ParticipantStatusRes; @@ -24,7 +24,7 @@ @Service @RequiredArgsConstructor -public class WendyServiceImpl implements WendyService { +public class DiscordWendyServiceImpl implements DiscordWendyService { private final VoteService voteService; private final ParticipantService participantService; @@ -41,7 +41,7 @@ public class WendyServiceImpl implements WendyService { // 생성된 투표 링크 private final Map channelShareUrl = new ConcurrentHashMap<>(); - + // 투표 생성 여부 (재투표 체크용) private final Set hasVote = ConcurrentHashMap.newKeySet(); @@ -60,14 +60,14 @@ public void startSession(String channelId, List members) { voteCreatedAt.remove(channelId); voteWeeks.remove(channelId); - System.out.println("[When:D] Session started: " + 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); @@ -79,35 +79,30 @@ public void endSession(String channelId) { hasVote.remove(channelId); voteCreatedAt.remove(channelId); voteWeeks.remove(channelId); - System.out.println("[When:D] Session ended: " + channelId); + System.out.println("[Discord When:D] Session ended: " + channelId); } - + @Override public void addParticipant(String channelId, String memberId, String memberName) { - // 1. 디스코드 참석자 목록에 추가 (기존 값이 있었는지 확인) Map channelParticipants = participants.computeIfAbsent(channelId, k -> new ConcurrentHashMap<>()); String previousName = channelParticipants.put(memberId, memberName); Long voteId = channelVoteId.get(channelId); if (voteId == null) { - // 아직 투표가 생성되지 않았다면, createVote 시점에 한 번에 도메인 Participant 생성 - System.out.println("[When:D] Participant added BEFORE vote: " + memberName + System.out.println("[Discord When:D] Participant added BEFORE vote: " + memberName + " (discordId=" + memberId + ")"); return; } - // 이미 이 디스코드 유저가 참석자 목록에 있었던 경우라면, - // (초기 참석자이거나, 이전에 한 번 추가되었던 경우) 도메인 Participant를 중복 생성하지 않는다. if (previousName != null) { - System.out.println("[When:D] Participant already exists AFTER vote: " + memberName + System.out.println("[Discord When:D] Participant already exists AFTER vote: " + memberName + " (discordId=" + memberId + ")"); return; } - // 2. 투표가 생성된 이후 처음 합류하는 참석자에 대해서만 도메인 Participant 생성 ParticipantRes pRes = participantService.add(voteId, memberName); - System.out.println("[When:D] Participant added AFTER vote: " + memberName + System.out.println("[Discord When:D] Participant added AFTER vote: " + memberName + " (discordId=" + memberId + ", participantId=" + pRes.id() + ")"); } @@ -121,12 +116,11 @@ public void removeParticipant(String channelId, String memberId) { Long voteId = channelVoteId.get(channelId); if (voteId == null) { - System.out.println("[When:D] Participant removed BEFORE vote: " + System.out.println("[Discord When:D] Participant removed BEFORE vote: " + (removedName != null ? removedName : memberId) + " (discordId=" + memberId + ")"); } else { - // 현재는 디스코드 쪽 참석자 목록에서만 제rㅓ - System.out.println("[When:D] Participant removed AFTER vote (domain not deleted): " + System.out.println("[Discord When:D] Participant removed AFTER vote (domain not deleted): " + (removedName != null ? removedName : memberId) + " (discordId=" + memberId + ")"); } @@ -139,28 +133,23 @@ public String createVote(String channelId, String channelName, int weeks) { voteCreatedAt.put(channelId, LocalDateTime.now()); voteWeeks.put(channelId, weeks); - // 1. 날짜 범위 계산 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 { - // n주 후: n주 뒤 월요일 ~ 일요일 LocalDate mondayThisWeek = today.with(DayOfWeek.MONDAY); startDate = mondayThisWeek.plusWeeks(weeks); endDate = startDate.plusDays(6); } - // 2. 참여자 이름 리스트 만들기 Map channelParticipants = participants.getOrDefault(channelId, Map.of()); List participantNames = new ArrayList<>(channelParticipants.values()); - // 3. 투표 생성 DTO 구성 CreateVoteReq req = new CreateVoteReq( channelName, startDate, @@ -168,21 +157,18 @@ public String createVote(String channelId, String channelName, int weeks) { participantNames.isEmpty() ? null : participantNames ); - // 4. 투표 생성 VoteSummary summary = voteService.create(req); Long voteId = summary.id(); String shareUrl = summary.shareUrl(); channelShareUrl.put(channelId, shareUrl); - // 5. channelId -> voteId 매핑 저장 channelVoteId.put(channelId, voteId); - - System.out.println("[When:D] Vote created for channel " + channelId + " (voteId=" + 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); @@ -205,7 +191,6 @@ public List getNonVoterIds(String channelId) { return List.of(); } - // 1. submitted=false 인 사람들만 이름 수집 List statuses = participantService.getParticipantStatusByVoteId(voteId); @@ -214,7 +199,6 @@ public List getNonVoterIds(String channelId) { .map(ParticipantStatusRes::displayName) .collect(Collectors.toSet()); - // 2. 디스코드 참석자 중 submitted=false 인 사람만 discordId 반환 List nonVoters = new ArrayList<>(); for (Map.Entry entry : channelParticipants.entrySet()) { if (nonSubmittedNames.contains(entry.getValue())) { @@ -224,20 +208,18 @@ public List getNonVoterIds(String channelId) { return nonVoters; } - + @Override public boolean hasPreviousVote(String channelId) { return hasVote.contains(channelId); } - + @Override public String recreateVote(String channelId, String channelName, int weeks) { - // 이전 voteId 사용하지 않고, - // 채널에 저장된 디스코드 참석자 목록을 기준으로 새 투표를 생성 channelVoteId.remove(channelId); String shareUrl = createVote(channelId, channelName, weeks); - System.out.println("[When:D] Vote recreated for channel " + channelId + " (weeks=" + weeks + ")"); + System.out.println("[Discord When:D] Vote recreated for channel " + channelId + " (weeks=" + weeks + ")"); return shareUrl; } @@ -269,14 +251,13 @@ public String getTopRankedDateTime(String channelId) { return "1순위 일정"; } - // 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(); // LUNCH or DINNER + String period = top.period(); String dayLabel = switch (date.getDayOfWeek()) { case MONDAY -> "월"; @@ -304,5 +285,4 @@ public String getChannelIdByVoteId(Long voteId) { } 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..419c264 --- /dev/null +++ b/src/main/java/com/workingdead/chatbot/kakao/controller/KakaoSkillController.java @@ -0,0 +1,321 @@ +package com.workingdead.chatbot.kakao.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.workingdead.chatbot.kakao.dto.KakaoRequest; +import com.workingdead.chatbot.kakao.dto.KakaoResponse; +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 오픈빌더 스킬 서버 컨트롤러 + * + * 카카오톡 챗봇에서 발화를 받아 처리하고 응답을 반환합니다. + * - 개인챗: userKey 기반 세션 + * - 그룹챗: botGroupKey 기반 세션 + */ +@Tag(name = "Kakao Chatbot", description = "카카오 챗봇 스킬 API") +@RestController +@RequestMapping("/kakao/skill") +@RequiredArgsConstructor +@Slf4j +public class KakaoSkillController { + + private final KakaoWendyService kakaoWendyService; + private final ObjectMapper objectMapper; + + /** + * 세션 키 결정 (그룹챗이면 botGroupKey, 개인챗이면 userKey) + */ + private String getSessionKey(KakaoRequest request) { + String botGroupKey = request.getBotGroupKey(); + if (botGroupKey != null && !botGroupKey.isBlank()) { + return botGroupKey; + } + return request.getUserKey(); + } + + /** + * 메인 스킬 엔드포인트 (폴백 블록) + * 모든 발화를 여기서 처리 + */ + @Operation(summary = "메인 스킬 (폴백)") + @PostMapping("/main") + public ResponseEntity handleMain(@RequestBody KakaoRequest request) { + String sessionKey = getSessionKey(request); + String botGroupKey = request.getBotGroupKey(); + String botUserKey = request.getBotUserKey(); + String utterance = request.getUtterance(); + + try { + log.info("[Kakao Skill Raw Request] {}", objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(request)); + } catch (Exception e) { + log.warn("[Kakao Skill] Failed to log raw request: {}", e.getMessage()); + } + + + + log.info("[Kakao Skill] sessionKey={}, botGroupKey={}, botUserKey={}, utterance={}", + sessionKey, botGroupKey, botUserKey, utterance); + + if (sessionKey == null || sessionKey.isBlank()) { + log.warn("[Kakao Skill] Missing sessionKey. botGroupKey={}, userKey={}, botUserKey={}", + botGroupKey, request.getUserKey(), botUserKey); + return ResponseEntity.ok(kakaoWendyService.help()); + } + + + 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(sessionKey, botGroupKey)); + } + + // 2. 도움말 + if (trimmed.equals("웬디 도움말") || trimmed.equals("도움말") || trimmed.equals("/help")) { + return ResponseEntity.ok(kakaoWendyService.help()); + } + + // 3. 웬디 종료 + if (trimmed.equals("웬디 종료") || trimmed.equals("종료")) { + return ResponseEntity.ok(kakaoWendyService.endSession(sessionKey)); + } + + // 4. 웬디 결과 + if (trimmed.equals("웬디 결과") || trimmed.equals("결과") || trimmed.equals("결과 확인")) { + return ResponseEntity.ok(kakaoWendyService.getVoteResult(sessionKey)); + } + + // 5. 웬디 재투표 + if (trimmed.equals("웬디 재투표") || trimmed.equals("재투표")) { + return ResponseEntity.ok(kakaoWendyService.revote(sessionKey)); + } + + // 6. 웬디 {기간} (예: "웬디 2주 후", "웬디 이번주") + // 멘션/푸시 기능 없이, 기간 입력을 받으면 바로 투표 URL을 생성해 반환 + if (trimmed.startsWith("웬디 ")) { + String arg = trimmed.substring("웬디 ".length()).trim(); + // 예약어는 위에서 이미 처리했지만, 안전하게 한 번 더 방어 + if (!arg.isBlank() + && !arg.equals("시작") + && !arg.equals("도움말") + && !arg.equals("종료") + && !arg.equals("결과") + && !arg.equals("재투표") + && !arg.equals("독촉")) { + Integer weeks = kakaoWendyService.parseWeeks(arg); + if (weeks != null) { + KakaoResponse response = kakaoWendyService.createVote(sessionKey, weeks, botGroupKey); + return ResponseEntity.ok(response); + } + } + } + + + // 세션 상태에 따른 처리 + SessionState state = kakaoWendyService.getSessionState(sessionKey); + + switch (state) { + case WAITING_PARTICIPANTS: + // 참석자 입력: PRD 기준으로 botUserKey(멘션된 유저 키) 기반을 우선 사용 + // 멘션 기반 참석자 수집 기능을 사용하지 않는 정책으로 전환 + return ResponseEntity.ok(KakaoResponse.simpleText( + "\"@웬디 2주 후\"처럼 기간을 입력하면 바로 날짜 투표 링크를 만들어드릴게요!" + )); + + case WAITING_WEEKS: + // 주차 선택 + Integer weeks = kakaoWendyService.parseWeeks(trimmed); + if (weeks != null) { + KakaoResponse response = kakaoWendyService.createVote(sessionKey, weeks, botGroupKey); + return ResponseEntity.ok(response); + } + break; + + default: + break; + } + + // 알 수 없는 입력 + return ResponseEntity.ok(kakaoWendyService.unknownInput(sessionKey)); + } + + /** + * 웬디 시작 스킬 (전용 블록) + */ + @Operation(summary = "웬디 시작") + @PostMapping("/start") + public ResponseEntity handleStart(@RequestBody KakaoRequest request) { + String sessionKey = getSessionKey(request); + String botGroupKey = request.getBotGroupKey(); + log.info("[Kakao Skill] START - sessionKey={}, botGroupKey={}", sessionKey, botGroupKey); + return ResponseEntity.ok(kakaoWendyService.startSession(sessionKey, botGroupKey)); + } + + /** + * 참석자 등록 스킬 (전용 블록) + * PRD 기준: 발화에서 멘션된 유저 식별 결과로 botUserKey 목록을 params로 전달받는 형태를 우선 지원 + * - 지원 params 키 예시: botUserKeys / participants / mentionedUserKeys + */ + @Operation(summary = "참석자 등록") + @PostMapping("/participants") + public ResponseEntity handleParticipants(@RequestBody KakaoRequest request) { + String sessionKey = getSessionKey(request); + log.info("[Kakao Skill] PARTICIPANTS - disabled. sessionKey={}", sessionKey); + return ResponseEntity.ok(KakaoResponse.simpleText( + "\"@웬디 2주 후\"처럼 기간을 입력하면 날짜 투표 링크를 만들어드릴게요!" + )); + } + + /** + * 웬디 종료 스킬 (전용 블록) + */ + @Operation(summary = "웬디 종료") + @PostMapping("/end") + public ResponseEntity handleEnd(@RequestBody KakaoRequest request) { + String sessionKey = getSessionKey(request); + log.info("[Kakao Skill] END - sessionKey={}", sessionKey); + return ResponseEntity.ok(kakaoWendyService.endSession(sessionKey)); + } + + /** + * 주차 선택 스킬 (전용 블록) + * params에서 weeks 값을 받음 + */ + @Operation(summary = "주차 선택") + @PostMapping("/select-week") + public ResponseEntity handleSelectWeek(@RequestBody KakaoRequest request) { + String sessionKey = getSessionKey(request); + String botGroupKey = request.getBotGroupKey(); + String weeksParam = request.getParam("weeks"); + + log.info("[Kakao Skill] SELECT_WEEK - sessionKey={}, botGroupKey={}, weeksParam={}", sessionKey, botGroupKey, weeksParam); + + if (sessionKey == null || sessionKey.isBlank()) { + log.warn("[Kakao Skill] SELECT_WEEK missing sessionKey. botGroupKey={}, userKey={}", + botGroupKey, request.getUserKey()); + return ResponseEntity.ok(KakaoResponse.simpleText("세션 정보를 확인하지 못했어요. 다시 시작해 주세요.")); + } + // 상태 검증: 주차 선택 단계에서만 허용 + SessionState state = kakaoWendyService.getSessionState(sessionKey); + if (state != SessionState.WAITING_WEEKS) { + log.warn("[Kakao Skill] SELECT_WEEK called in invalid state. sessionKey={}, state={}", sessionKey, state); + return ResponseEntity.ok(kakaoWendyService.unknownInput(sessionKey)); + } + + // weeks 파싱 (param 우선, 없으면 utterance로 보조) + String candidate = (weeksParam != null && !weeksParam.isBlank()) ? weeksParam : request.getUtterance(); + Integer weeks = (candidate == null) ? null : kakaoWendyService.parseWeeks(candidate.trim()); + + if (weeks == null || weeks < 0) { + log.warn("[Kakao Skill] SELECT_WEEK invalid weeks. sessionKey={}, candidate={}", sessionKey, candidate); + return ResponseEntity.ok(KakaoResponse.simpleText("주차 선택 값을 확인하지 못했어요. 다시 선택해 주세요.")); + } + + KakaoResponse response = kakaoWendyService.createVote(sessionKey, weeks, botGroupKey); + return ResponseEntity.ok(response); + } + + /** + * 결과 조회 스킬 (전용 블록) + */ + @Operation(summary = "투표 결과 조회") + @PostMapping("/result") + public ResponseEntity handleResult(@RequestBody KakaoRequest request) { + String sessionKey = getSessionKey(request); + log.info("[Kakao Skill] RESULT - sessionKey={}", sessionKey); + return ResponseEntity.ok(kakaoWendyService.getVoteResult(sessionKey)); + } + + /** + * 재투표 스킬 (전용 블록) + */ + @Operation(summary = "재투표") + @PostMapping("/revote") + public ResponseEntity handleRevote(@RequestBody KakaoRequest request) { + String sessionKey = getSessionKey(request); + log.info("[Kakao Skill] REVOTE - sessionKey={}", sessionKey); + return ResponseEntity.ok(kakaoWendyService.revote(sessionKey)); + } + + /** + * 도움말 스킬 (전용 블록) + */ + @Operation(summary = "도움말") + @PostMapping("/help") + public ResponseEntity handleHelp(@RequestBody KakaoRequest request) { + log.info("[Kakao Skill] HELP - sessionKey={}", getSessionKey(request)); + return ResponseEntity.ok(kakaoWendyService.help()); + } + + /** + * 헬스체크 (카카오 스킬 서버 상태 확인용) + */ + @Operation(summary = "헬스체크") + @GetMapping("/health") + public ResponseEntity health() { + return ResponseEntity.ok("OK"); + } + + + /** + * 참석자 botUserKey 목록 추출 + * - 오픈빌더의 "발화에서 멘션된 유저 식별" 결과를 params로 전달받는 것을 1순위로 사용 + * - 지원 키: botUserKeys / participants / mentionedUserKeys + * - 값 형태: "k1,k2,k3" 또는 "k1 k2 k3" 등(구분자는 콤마/공백/개행 모두 허용) + * + * @param request 요청 DTO + * @param fallbackUtterance params가 없을 때 마지막 fallback(테스트/디버그용) + * @return 콤마(,)로 join된 botUserKey 목록 문자열 + */ + private String extractParticipantKeys(KakaoRequest request, String fallbackUtterance) { + String raw = firstNonBlank( + request.getParam("botUserKeys"), + request.getParam("participants"), + request.getParam("mentionedUserKeys") + ); + + if (raw == null || raw.isBlank()) { + raw = (fallbackUtterance == null) ? "" : fallbackUtterance; + } + + return normalizeKeys(raw); + } + + private String firstNonBlank(String... values) { + if (values == null) return null; + for (String v : values) { + if (v != null && !v.isBlank()) return v; + } + return null; + } + + /** + * 다양한 구분자(콤마/공백/개행)를 콤마 구분 문자열로 정규화 + */ + private String normalizeKeys(String raw) { + if (raw == null) return ""; + String trimmed = raw.trim(); + if (trimmed.isEmpty()) return ""; + + // 콤마, 공백, 개행, 탭을 모두 구분자로 처리 + String[] parts = trimmed.split("[\\s,]+"); + java.util.LinkedHashSet set = new java.util.LinkedHashSet<>(); + for (String p : parts) { + if (p == null) continue; + String s = p.trim(); + if (!s.isEmpty()) set.add(s); + } + return String.join(",", set); + } +} \ 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..e0531ab --- /dev/null +++ b/src/main/java/com/workingdead/chatbot/kakao/dto/KakaoRequest.java @@ -0,0 +1,182 @@ +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; + private Chat chat; // 그룹 채팅방 정보 + + @Getter + @Setter + @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Chat { + private String id; // 채팅방 ID (botGroupKey) + private String type; // 채팅방 타입 + } + + @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 null; + + Properties p = userRequest.getUser().getProperties(); + if (p != null) { + String plusfriendUserKey = p.getPlusfriendUserKey(); + if (plusfriendUserKey != null && !plusfriendUserKey.isBlank()) { + return plusfriendUserKey; + } + } + String id = userRequest.getUser().getId(); + return (id == null || id.isBlank()) ? null : id; + } + + public String getUtterance() { + if (userRequest != null) { + return userRequest.getUtterance(); + } + return null; + } + + public String getParam(String key) { + if (key == null || key.isBlank()) return null; + + // 1) action.params 우선 + if (action != null && action.getParams() != null) { + String v = action.getParams().get(key); + if (v != null) return v; + } + + // 2) userRequest.params fallback (오픈빌더 설정에 따라 여기로 들어오는 케이스 대비) + if (userRequest != null && userRequest.getParams() != null) { + Object v = userRequest.getParams().get(key); + return v == null ? null : String.valueOf(v); + } + + return null; + } + + public String getBotId() { + if (bot != null) { + return bot.getId(); + } + return null; + } + + /** + * 그룹 채팅방 키 (botGroupKey) 조회 + */ + public String getBotGroupKey() { + if (chat != null) { + return chat.getId(); + } + return null; + } + + /** + * 그룹 채팅방 여부 확인 + */ + public boolean isGroupChat() { + return chat != null && chat.getId() != null; + } + + /** + * 그룹챗 사용자 식별용 botUserKey + * - PRD 기준: 멘션/참석자 식별 키로 사용 + * - 현재 구조에서는 user.id를 사용 + */ + public String getBotUserKey() { + if (userRequest != null && userRequest.getUser() != null) { + String id = userRequest.getUser().getId(); + return (id == null || id.isBlank()) ? null : id; + } + 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..9bb4f7f --- /dev/null +++ b/src/main/java/com/workingdead/chatbot/kakao/dto/KakaoResponse.java @@ -0,0 +1,365 @@ +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; + private Extra extra; // 멘션 등 추가 기능 + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Extra { + private Map mentions; // key: 텍스트에서 사용할 키, value: botUserKey + } + + @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