diff --git a/build.gradle b/build.gradle index 0ead835..5372148 100644 --- a/build.gradle +++ b/build.gradle @@ -52,9 +52,13 @@ dependencies { implementation 'io.micrometer:micrometer-registry-prometheus' // Slack API - implementation("com.slack.api:bolt:1.18.0") - implementation("com.slack.api:bolt-servlet:1.18.0") - implementation("com.slack.api:bolt-jetty:1.18.0") + implementation 'com.slack.api:bolt:1.18.0' + implementation 'com.slack.api:bolt-servlet:1.18.0' + implementation 'com.slack.api:bolt-jetty:1.18.0' + implementation 'com.slack.api:slack-api-client:1.44.1' + + // Discord API + implementation 'net.dv8tion:JDA:5.0.0-beta.5' } tasks.named('test') { diff --git a/src/main/java/com/Alchive/backend/config/JDAConfig.java b/src/main/java/com/Alchive/backend/config/JDAConfig.java new file mode 100644 index 0000000..b3e5993 --- /dev/null +++ b/src/main/java/com/Alchive/backend/config/JDAConfig.java @@ -0,0 +1,21 @@ +package com.Alchive.backend.config; + +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.JDABuilder; +import net.dv8tion.jda.api.exceptions.RateLimitedException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import javax.security.auth.login.LoginException; + +@Configuration +public class JDAConfig { + @Value("${DISCORD_BOT_TOKEN}") + private String discordBotToken; + @Bean + public JDA jda() throws LoginException, RateLimitedException { + return JDABuilder.createDefault(discordBotToken) + .build(); + } +} diff --git a/src/main/java/com/Alchive/backend/config/SwaggerConfig.java b/src/main/java/com/Alchive/backend/config/SwaggerConfig.java index 9516445..a0445a8 100644 --- a/src/main/java/com/Alchive/backend/config/SwaggerConfig.java +++ b/src/main/java/com/Alchive/backend/config/SwaggerConfig.java @@ -43,7 +43,7 @@ public OpenAPI openAPI() { private Info apiInfo() { return new Info() .title("Alchive API") - .description("[ Base URL: http://localhost:8080/api/v1]\n\nAlchive의 API 문서") + .description("[ Base URL: http://localhost:8080/api/v2]\n\nAlchive의 API 문서") .version("1.0.0"); } } \ No newline at end of file diff --git a/src/main/java/com/Alchive/backend/config/error/ErrorCode.java b/src/main/java/com/Alchive/backend/config/error/ErrorCode.java index 246a29b..21f7eba 100644 --- a/src/main/java/com/Alchive/backend/config/error/ErrorCode.java +++ b/src/main/java/com/Alchive/backend/config/error/ErrorCode.java @@ -33,7 +33,11 @@ public enum ErrorCode { PROBLEM_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "P001", "문제가 존재하지 않음"), // SNS - SNS_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "N001", "소셜이 존재하지 않음"); + SNS_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "N001", "소셜이 존재하지 않음"), + + // DISCORD + INVALID_GRANT(HttpStatus.FORBIDDEN.value(), "D001", "승인되지 않은 코드"), + DISCORD_USER_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "D002", "존재하지 않는 디스코드 유저"); private final int httpStatus; private final String code; diff --git a/src/main/java/com/Alchive/backend/config/error/exception/sns/InvalidGrantException.java b/src/main/java/com/Alchive/backend/config/error/exception/sns/InvalidGrantException.java new file mode 100644 index 0000000..65c4676 --- /dev/null +++ b/src/main/java/com/Alchive/backend/config/error/exception/sns/InvalidGrantException.java @@ -0,0 +1,8 @@ +package com.Alchive.backend.config.error.exception.sns; + +import com.Alchive.backend.config.error.ErrorCode; +import com.Alchive.backend.config.error.exception.BusinessException; + +public class InvalidGrantException extends BusinessException { + public InvalidGrantException() { super(ErrorCode.INVALID_GRANT); } +} diff --git a/src/main/java/com/Alchive/backend/config/error/exception/sns/NoSuchDiscordUserException.java b/src/main/java/com/Alchive/backend/config/error/exception/sns/NoSuchDiscordUserException.java new file mode 100644 index 0000000..227532a --- /dev/null +++ b/src/main/java/com/Alchive/backend/config/error/exception/sns/NoSuchDiscordUserException.java @@ -0,0 +1,8 @@ +package com.Alchive.backend.config.error.exception.sns; + +import com.Alchive.backend.config.error.ErrorCode; +import com.Alchive.backend.config.error.exception.BusinessException; + +public class NoSuchDiscordUserException extends BusinessException { + public NoSuchDiscordUserException() { super(ErrorCode.DISCORD_USER_NOT_FOUND); } +} diff --git a/src/main/java/com/Alchive/backend/config/jwt/JwtController.java b/src/main/java/com/Alchive/backend/config/jwt/JwtController.java index 6aec2d4..8eef923 100644 --- a/src/main/java/com/Alchive/backend/config/jwt/JwtController.java +++ b/src/main/java/com/Alchive/backend/config/jwt/JwtController.java @@ -4,6 +4,7 @@ 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.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; diff --git a/src/main/java/com/Alchive/backend/config/result/ResultCode.java b/src/main/java/com/Alchive/backend/config/result/ResultCode.java index f83581b..792d6ef 100644 --- a/src/main/java/com/Alchive/backend/config/result/ResultCode.java +++ b/src/main/java/com/Alchive/backend/config/result/ResultCode.java @@ -33,7 +33,13 @@ public enum ResultCode { // SNS SNS_INFO_SUCCESS("N001", "소셜 조회 성공"), - SNS_CREATE_SUCCESS("N002", "소셜 생성 성공"); + SNS_CREATE_SUCCESS("N002", "소셜 생성 성공"), + + // DISCORD + DISCORD_DM_SEND_SUCCESS("D001", "디스코드 DM 전송 성공"), + + // SLACK + SLACK_DM_SEND_SUCCESS("L001", "슬랙 DM 전송 성공"); private final String code; private final String message; } diff --git a/src/main/java/com/Alchive/backend/controller/BoardController.java b/src/main/java/com/Alchive/backend/controller/BoardController.java index e09cd9c..f18abb0 100644 --- a/src/main/java/com/Alchive/backend/controller/BoardController.java +++ b/src/main/java/com/Alchive/backend/controller/BoardController.java @@ -9,7 +9,7 @@ import com.Alchive.backend.dto.response.BoardDetailResponseDTO; import com.Alchive.backend.dto.response.BoardResponseDTO; import com.Alchive.backend.service.BoardService; -import com.Alchive.backend.slack.SlackService; +import com.Alchive.backend.service.SlackService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; diff --git a/src/main/java/com/Alchive/backend/controller/DiscordController.java b/src/main/java/com/Alchive/backend/controller/DiscordController.java new file mode 100644 index 0000000..2e849ec --- /dev/null +++ b/src/main/java/com/Alchive/backend/controller/DiscordController.java @@ -0,0 +1,68 @@ +package com.Alchive.backend.controller; + +import com.Alchive.backend.config.result.ResultResponse; +import com.Alchive.backend.domain.sns.Sns; +import com.Alchive.backend.domain.sns.SnsCategory; +import com.Alchive.backend.domain.user.User; +import com.Alchive.backend.dto.request.SnsCreateRequest; +import com.Alchive.backend.service.SnsService; +import com.Alchive.backend.service.DiscordService; +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.*; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import static com.Alchive.backend.config.result.ResultCode.DISCORD_DM_SEND_SUCCESS; + +@Tag(name = "디스코드", description = "디스코드 관련 api입니다. ") +@RequiredArgsConstructor +@RestController +@Slf4j +@RequestMapping("/api/v2/discord") +public class DiscordController { + private final SnsService snsService; + private final DiscordService discordService; + + @Operation(summary = "디스코드 봇 연결", description = "디스코드 액세스 토큰을 요청하고 DM 채널을 연결하는 api입니다. ") + @GetMapping("/dm/open") + public ResponseEntity openDiscordDm(@AuthenticationPrincipal User user, @RequestParam String code) { + // Access Token 요청 + String accessToken = discordService.getAccessToken(code); + log.info("Access Token 반환 완료: " + accessToken); + + // 사용자 DISCORD USER ID 요청 + String discordUserId = discordService.getDiscordUserIdFromAccessToken(accessToken); + log.info("Discord User Id 반환 완료: " + discordUserId); + + // DM 채널 생성 요청 + String channelId = discordService.getDmChannel(discordUserId); + log.info("DM 채널 생성 완료"); + + // Discord SNS 정보 저장 + SnsCreateRequest snsCreateRequest = SnsCreateRequest.builder() + .category(SnsCategory.DISCORD) + .sns_id(discordUserId) // Discord User Id + .channel_id(channelId) // Discord Channel Id + .time("0 0 18 ? * MON") + .build(); + snsService.createSns(user, snsCreateRequest); + log.info("SNS 정보 저장 완료"); + + // DM 전송 요청 + discordService.sendDm(channelId, "안녕하세요! 이제부터 풀지 못한 문제들을 정해진 시간에 알려드릴게요. "); + + return ResponseEntity.ok(ResultResponse.of(DISCORD_DM_SEND_SUCCESS)); + } + + @Operation(summary = "디스코드 DM 전송", description = "디스코드 DM으로 메시지를 전송하는 api입니다. ") + @PostMapping("dm/send") + public ResponseEntity sendDiscordDm(@AuthenticationPrincipal User user, @RequestParam String message) { + Sns discordInfo = discordService.getDiscordInfo(user); + discordService.sendDmJda(discordInfo.getSns_id(), message); + return ResponseEntity.ok(ResultResponse.of(DISCORD_DM_SEND_SUCCESS)); + } +} diff --git a/src/main/java/com/Alchive/backend/controller/SlackController.java b/src/main/java/com/Alchive/backend/controller/SlackController.java new file mode 100644 index 0000000..d3d9083 --- /dev/null +++ b/src/main/java/com/Alchive/backend/controller/SlackController.java @@ -0,0 +1,55 @@ +package com.Alchive.backend.controller; + +import com.Alchive.backend.config.result.ResultResponse; +import com.Alchive.backend.domain.sns.Sns; +import com.Alchive.backend.domain.user.User; +import com.Alchive.backend.dto.request.SnsCreateRequest; +import com.Alchive.backend.service.SnsService; +import com.Alchive.backend.service.SlackService; +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.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import static com.Alchive.backend.config.result.ResultCode.SLACK_DM_SEND_SUCCESS; + +@Slf4j +@Tag(name = "슬랙", description = "슬랙 관련 API입니다. ") +@RestController +@RequestMapping("/api/v2/slack") +@RequiredArgsConstructor +public class SlackController { + private final SlackService slackService; + private final SnsService snsService; + + @Operation(summary = "슬랙 봇 연결", description = "슬랙 액세스 토큰을 요청하고 DM 채널을 연결하는 api입니다. ") + @GetMapping("/dm/open") + public ResponseEntity openSlackDm(@AuthenticationPrincipal User user, @RequestParam String code) { + // Bot Access Token, User Access Token, Slack User Id 요청 + SnsCreateRequest snsCreateRequest = slackService.getSlackInfo(code); + log.info("사용자 slack 정보를 불러왔습니다. "); + + // Slack SNS 정보 저장 + snsService.createSns(user, snsCreateRequest); + log.info("사용자 slack 정보를 저장했습니다. "); + + // DM 전송 요청 + String slackUserId = snsCreateRequest.getSns_id(); + String botAccessToken = snsCreateRequest.getBot_token(); + + slackService.sendDm(slackUserId, botAccessToken, "안녕하세요! 이제부터 풀지 못한 문제들을 정해진 시간에 알려드릴게요. "); + log.info("봇을 사용자 slack 채널에 추가했습니다. "); + return ResponseEntity.ok(ResultResponse.of(SLACK_DM_SEND_SUCCESS)); + } + + @Operation(summary = "슬랙 DM 전송", description = "슬랙 DM으로 메시지를 전송하는 api입니다. ") + @PostMapping("dm/send") + public ResponseEntity sendSlackDm(@AuthenticationPrincipal User user, @RequestParam String message) { + Sns sns = slackService.getSlackInfo(user); + slackService.sendDm(sns.getSns_id(), sns.getBot_token(), message); + return ResponseEntity.ok(ResultResponse.of(SLACK_DM_SEND_SUCCESS)); + } +} \ No newline at end of file diff --git a/src/main/java/com/Alchive/backend/domain/sns/Sns.java b/src/main/java/com/Alchive/backend/domain/sns/Sns.java index dcde82e..24efefe 100644 --- a/src/main/java/com/Alchive/backend/domain/sns/Sns.java +++ b/src/main/java/com/Alchive/backend/domain/sns/Sns.java @@ -30,11 +30,17 @@ public class Sns extends BaseEntity { @Column(name = "category", length = 20, nullable = false) private SnsCategory category; - @Column(name = "token", columnDefinition = "TEXT", nullable = false) - private String token; + @Column(name = "bot_token", columnDefinition = "TEXT") + private String bot_token; - @Column(name = "channel", columnDefinition = "TEXT") - private String channel; + @Column(name = "user_token", columnDefinition = "TEXT") + private String user_token; + + @Column(name = "channel_id", columnDefinition = "TEXT") + private String channel_id; + + @Column(name = "sns_id", columnDefinition = "TEXT") + private String sns_id; @Column(name = "time", length = 30) private String time; @@ -43,8 +49,10 @@ public static Sns of(User user, SnsCreateRequest snsCreateRequest) { return Sns.builder() .user(user) .category(snsCreateRequest.getCategory()) - .token(snsCreateRequest.getToken()) - .channel(snsCreateRequest.getChannel()) + .bot_token(snsCreateRequest.getBot_token()) + .user_token(snsCreateRequest.getUser_token()) + .channel_id(snsCreateRequest.getChannel_id()) + .sns_id(snsCreateRequest.getSns_id()) .time(snsCreateRequest.getTime()) .build(); } diff --git a/src/main/java/com/Alchive/backend/dto/request/SnsCreateRequest.java b/src/main/java/com/Alchive/backend/dto/request/SnsCreateRequest.java index 43fe7cd..1310e39 100644 --- a/src/main/java/com/Alchive/backend/dto/request/SnsCreateRequest.java +++ b/src/main/java/com/Alchive/backend/dto/request/SnsCreateRequest.java @@ -12,8 +12,9 @@ public class SnsCreateRequest { @NotNull(message = "소셜 카테고리는 필수입니다. ") private SnsCategory category; - @NotNull(message = "토큰은 필수입니다. ") - private String token; - private String channel; + private String bot_token; + private String user_token; + private String channel_id; + private String sns_id; private String time; } diff --git a/src/main/java/com/Alchive/backend/dto/response/SnsResponseDTO.java b/src/main/java/com/Alchive/backend/dto/response/SnsResponseDTO.java index 6fc5d92..585492f 100644 --- a/src/main/java/com/Alchive/backend/dto/response/SnsResponseDTO.java +++ b/src/main/java/com/Alchive/backend/dto/response/SnsResponseDTO.java @@ -15,8 +15,10 @@ public class SnsResponseDTO { private LocalDateTime createdAt; private LocalDateTime updatedAt; private SnsCategory category; - private String token; - private String channel; + private String bot_token; + private String user_token; + private String channel_id; + private String sns_id; private String time; public SnsResponseDTO(Sns sns) { @@ -24,8 +26,10 @@ public SnsResponseDTO(Sns sns) { this.createdAt = sns.getCreatedAt(); this.updatedAt = sns.getUpdatedAt(); this.category = sns.getCategory(); - this.token = sns.getToken(); - this.channel = sns.getChannel(); + this.bot_token = sns.getBot_token(); + this.user_token = sns.getUser_token(); + this.channel_id = sns.getChannel_id(); + this.sns_id = sns.getSns_id(); this.time = sns.getTime(); } } diff --git a/src/main/java/com/Alchive/backend/repository/BoardRepository.java b/src/main/java/com/Alchive/backend/repository/BoardRepository.java index 4d533df..43f1356 100644 --- a/src/main/java/com/Alchive/backend/repository/BoardRepository.java +++ b/src/main/java/com/Alchive/backend/repository/BoardRepository.java @@ -15,6 +15,6 @@ public interface BoardRepository extends JpaRepository { Optional findByProblem_PlatformAndProblem_NumberAndUser_Id(ProblemPlatform platform, int problemNumber, Long userId); - @Query(value = "SELECT * FROM Board WHERE created_at <= :threeDaysAgo AND status != 'CORRECT' ORDER BY RAND() LIMIT 1", nativeQuery = true) - Board findUnsolvedBoardAddedBefore(@Param("threeDaysAgo") LocalDateTime threeDaysAgo); + @Query(value = "SELECT * FROM Board WHERE createdAt <= :threeDaysAgo AND status != 'CORRECT' AND userId = :user_id ORDER BY RAND() LIMIT 1", nativeQuery = true) + Board findUnsolvedBoardAddedBefore(@Param("threeDaysAgo") LocalDateTime threeDaysAgo, @Param("user_id") Long userId); } diff --git a/src/main/java/com/Alchive/backend/repository/SnsReporitory.java b/src/main/java/com/Alchive/backend/repository/SnsReporitory.java index 31b8207..e29bdd8 100644 --- a/src/main/java/com/Alchive/backend/repository/SnsReporitory.java +++ b/src/main/java/com/Alchive/backend/repository/SnsReporitory.java @@ -1,6 +1,7 @@ package com.Alchive.backend.repository; import com.Alchive.backend.domain.sns.Sns; +import com.Alchive.backend.domain.sns.SnsCategory; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -9,4 +10,7 @@ @Repository public interface SnsReporitory extends JpaRepository { Optional findById(Long id); + Optional findByUser_IdAndCategory(Long userId, SnsCategory category); + + Boolean existsByUser_IdAndCategory(Long userId, SnsCategory category); } diff --git a/src/main/java/com/Alchive/backend/service/DiscordService.java b/src/main/java/com/Alchive/backend/service/DiscordService.java new file mode 100644 index 0000000..a3a0ce9 --- /dev/null +++ b/src/main/java/com/Alchive/backend/service/DiscordService.java @@ -0,0 +1,154 @@ +package com.Alchive.backend.service; + +import com.Alchive.backend.config.error.exception.sns.InvalidGrantException; +import com.Alchive.backend.config.error.exception.sns.NoSuchDiscordUserException; +import com.Alchive.backend.config.error.exception.sns.NoSuchSnsIdException; +import com.Alchive.backend.domain.board.Board; +import com.Alchive.backend.domain.sns.Sns; +import com.Alchive.backend.domain.sns.SnsCategory; +import com.Alchive.backend.repository.BoardRepository; +import com.Alchive.backend.repository.SnsReporitory; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.entities.User; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.HashMap; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@EnableScheduling +@Slf4j +public class DiscordService { + private final JDA jda; + private final BoardRepository boardRepository; + private final SnsReporitory snsReporitory; + + @Value("${DISCORD_CLIENT_ID}") + private String clientId; + + @Value("${DISCORD_CLIENT_SECRET}") + private String clientSecret; + + @Value("${DISCORD_REDIRECT_URI}") + private String redirectUri; + + @Value("${DISCORD_BOT_TOKEN}") + private String discordBotToken; + private RestTemplate restTemplate = new RestTemplate(); + + public String getAccessToken(String code) { + String getTokenUrl = "https://discord.com/api/oauth2/token"; + + HttpHeaders getTokenHeaders = new HttpHeaders(); + getTokenHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + MultiValueMap getTokenParams = new LinkedMultiValueMap<>(); + getTokenParams.add("client_id", clientId); + getTokenParams.add("client_secret", clientSecret); + getTokenParams.add("code", code); + getTokenParams.add("grant_type", "authorization_code"); + getTokenParams.add("redirect_uri", redirectUri); + + HttpEntity> request = new HttpEntity<>(getTokenParams, getTokenHeaders); + ResponseEntity response = restTemplate.postForEntity(getTokenUrl, request, Map.class); + Map responseBody = response.getBody(); + + if (responseBody.containsKey("error") && responseBody.get("error") == "invalid_grant" ) { + throw new InvalidGrantException(); + } + String accessToken = (String) responseBody.get("access_token"); + return accessToken; + } + + public String getDiscordUserIdFromAccessToken(String accessToken) { + String getUserInfoUrl = "https://discord.com/api/v10/users/@me"; + HttpHeaders accessTokenHeaders = new HttpHeaders(); + accessTokenHeaders.setBearerAuth(accessToken); + + HttpEntity authRequest = new HttpEntity<>(accessTokenHeaders); + ResponseEntity userInfoResponse = restTemplate.exchange(getUserInfoUrl, HttpMethod.GET, authRequest, Map.class); + Map userInfo = userInfoResponse.getBody(); + + String discordUserId = (String) userInfo.get("id"); + return discordUserId; + } + + public Sns getDiscordInfo(com.Alchive.backend.domain.user.User user) { + Long userId = user.getId(); + Sns discordInfo = snsReporitory.findByUser_IdAndCategory(userId, SnsCategory.DISCORD) + .orElseThrow(NoSuchSnsIdException::new); + return discordInfo; + } + + public void sendDm(String channelId, String message) { + String sendMessageUrl = "https://discord.com/api/v10/channels/" + channelId + "/messages"; + HttpHeaders sendDmHeaders = new HttpHeaders(); + sendDmHeaders.set("Authorization", "Bot " + discordBotToken); + sendDmHeaders.setContentType(MediaType.APPLICATION_JSON); + + Map sendDmParams = new HashMap<>(); + sendDmParams.put("content", message); + + HttpEntity> sendMessageRequest = new HttpEntity<>(sendDmParams, sendDmHeaders); + restTemplate.postForEntity(sendMessageUrl, sendMessageRequest, Map.class); + } + + public String getDmChannel(String discordUserId) { + String getDMChannelUrl = "https://discord.com/api/v10/users/@me/channels"; + Map getDmParams = new HashMap<>(); + getDmParams.put("recipient_id", discordUserId); + + HttpHeaders botTokenHeader = new HttpHeaders(); + botTokenHeader.setContentType(MediaType.APPLICATION_JSON); + botTokenHeader.set("Authorization", "Bot " + discordBotToken); + + HttpEntity> createDmRequest = new HttpEntity<>(getDmParams, botTokenHeader); + ResponseEntity dmResponse = restTemplate.postForEntity(getDMChannelUrl, createDmRequest, Map.class); + Map dmResponseBody = dmResponse.getBody(); + + String channelId = (String) dmResponseBody.get("id"); + return channelId; + } + + // JDA 사용 메서드 + public void sendDmJda(String discordUserId, String message) { + User user = jda.retrieveUserById(discordUserId).complete(); + if (user != null) { + user.openPrivateChannel().queue(channel -> + channel.sendMessage(message).queue() + ); + } else { + throw new NoSuchDiscordUserException(); + } + } + +// @Scheduled(cron = "0 */1 * * * *") // todo: Quartz로 동적 스케줄링 작성하기 + public void sendMessageReminderBoard(com.Alchive.backend.domain.user.User user) { + LocalDateTime threeDaysAgo = LocalDateTime.now().minusDays(1); + + Board unSolvedBoard = boardRepository.findUnsolvedBoardAddedBefore(threeDaysAgo, user.getId()); + if (unSolvedBoard != null) { + String message = String.format(":star-struck: %d일 전 도전했던 %d. %s 문제를 아직 풀지 못했어요. \n \n다시 도전해보세요! :facepunch: \n \n<%s|:link: 문제 풀러가기>", + ChronoUnit.DAYS.between(unSolvedBoard.getCreatedAt(), LocalDateTime.now()), + unSolvedBoard.getProblem().getNumber(), + unSolvedBoard.getProblem().getTitle(), + unSolvedBoard.getProblem().getUrl()); + + Sns discordInfo = getDiscordInfo(user); + sendDmJda(discordInfo.getSns_id(), message); + } else { + log.info("풀지 못한 문제가 존재하지 않습니다. "); + } + } +} diff --git a/src/main/java/com/Alchive/backend/service/SlackService.java b/src/main/java/com/Alchive/backend/service/SlackService.java new file mode 100644 index 0000000..88e9239 --- /dev/null +++ b/src/main/java/com/Alchive/backend/service/SlackService.java @@ -0,0 +1,128 @@ +package com.Alchive.backend.service; + +import com.Alchive.backend.config.error.exception.sns.InvalidGrantException; +import com.Alchive.backend.config.error.exception.sns.NoSuchSnsIdException; +import com.Alchive.backend.domain.board.Board; +import com.Alchive.backend.domain.sns.Sns; +import com.Alchive.backend.domain.sns.SnsCategory; +import com.Alchive.backend.domain.user.User; +import com.Alchive.backend.dto.request.SnsCreateRequest; +import com.Alchive.backend.repository.BoardRepository; +import com.Alchive.backend.repository.SnsReporitory; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.*; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + + +@Slf4j +@Service +@RequiredArgsConstructor +@EnableScheduling +@Configuration +public class SlackService { + @Value("${SLACK_CLIENT_ID}") + private String clientId; + + @Value("${SLACK_CLIENT_SECRET}") + private String clientSecret; + + @Value("${SLACK_REDIRECT_URI}") + private String redirectUri; + + @Value("${SLACK_BOT_TOKEN}") + private String slackBotToken; + + private RestTemplate restTemplate = new RestTemplate(); + private BoardRepository boardRepository; + private SnsReporitory snsReporitory; + + public SnsCreateRequest getSlackInfo(String code) { + String getTokenUrl = "https://slack.com/api/oauth.v2.access"; + + HttpHeaders getTokenHeaders = new HttpHeaders(); + getTokenHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + MultiValueMap getTokenParams = new LinkedMultiValueMap<>(); + getTokenParams.add("client_id", clientId); + getTokenParams.add("client_secret", clientSecret); + getTokenParams.add("code", code); + getTokenParams.add("redirect_uri", redirectUri); + + HttpEntity> request = new HttpEntity<>(getTokenParams, getTokenHeaders); + ResponseEntity reponse = restTemplate.postForEntity(getTokenUrl, request, Map.class); + Map responseBody = reponse.getBody(); + + if (responseBody.get("ok") == "false") { + throw new InvalidGrantException(); + } + + Map authedUser = (Map) responseBody.get("authed_user"); + String slackUserId = (String) authedUser.get("id"); + String botAccessToken = (String) responseBody.get("access_token"); + String userAccessToken = (String) authedUser.get("access_token"); + + SnsCreateRequest snsCreateRequest = SnsCreateRequest.builder() + .category(SnsCategory.SLACK) + .sns_id(slackUserId) + .bot_token(botAccessToken) + .user_token(userAccessToken) + .time("0 0 18 ? * MON") + .build(); + + return snsCreateRequest; + } + + public Sns getSlackInfo(User user) { + Long userId = user.getId(); + Sns slackInfo = snsReporitory.findByUser_IdAndCategory(userId, SnsCategory.SLACK) + .orElseThrow(NoSuchSnsIdException::new); + return slackInfo; + } + + public void sendDm(String slackUserId, String botAccessToken, String message) { + String sendDmUrl = "https://slack.com/api/chat.postMessage"; + HttpHeaders sendDmHeaders = new HttpHeaders(); + sendDmHeaders.setContentType(MediaType.APPLICATION_JSON); + sendDmHeaders.setBearerAuth(botAccessToken); + + Map sendDmParams = new HashMap<>(); + sendDmParams.put("channel", slackUserId); + sendDmParams.put("text", message); + + HttpEntity> request = new HttpEntity<>(sendDmParams, sendDmHeaders); + restTemplate.postForEntity(sendDmUrl, request, Map.class); + } + + // @Scheduled(cron = "0 0 * * * *") // todo: Quartz로 동적 스케줄링 작성하기 + public void sendMessageReminderBoard(User user) { + LocalDateTime threeDaysAgo = LocalDateTime.now().minusDays(1); + + Board unSolvedBoard = boardRepository.findUnsolvedBoardAddedBefore(threeDaysAgo, user.getId()); + if (unSolvedBoard != null) { + String message = String.format(":star-struck: %d일 전 도전했던 %d. %s 문제를 아직 풀지 못했어요. \n \n다시 도전해보세요! :facepunch: \n \n<%s|:link: 문제 풀러가기>", + ChronoUnit.DAYS.between(unSolvedBoard.getCreatedAt(), LocalDateTime.now()), + unSolvedBoard.getProblem().getNumber(), + unSolvedBoard.getProblem().getTitle(), + unSolvedBoard.getProblem().getUrl()); + + Sns slackInfo = getSlackInfo(user); + sendDm(slackInfo.getSns_id(), slackInfo.getBot_token(), message); + } else { + log.info("풀지 못한 문제가 존재하지 않습니다. "); + } + } +} diff --git a/src/main/java/com/Alchive/backend/service/SnsService.java b/src/main/java/com/Alchive/backend/service/SnsService.java index 8adc870..8dce77d 100644 --- a/src/main/java/com/Alchive/backend/service/SnsService.java +++ b/src/main/java/com/Alchive/backend/service/SnsService.java @@ -8,10 +8,12 @@ import com.Alchive.backend.repository.SnsReporitory; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @RequiredArgsConstructor @Service +@Slf4j public class SnsService { private final SnsReporitory snsReporitory; diff --git a/src/main/java/com/Alchive/backend/slack/SlackController.java b/src/main/java/com/Alchive/backend/slack/SlackController.java deleted file mode 100644 index a40fb25..0000000 --- a/src/main/java/com/Alchive/backend/slack/SlackController.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.Alchive.backend.slack; - -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.beans.factory.annotation.Autowired; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@Slf4j -@Tag(name = "슬랙", description = "슬랙 관련 API입니다. ") -@RestController -@RequestMapping("/api/v1/slack") -@RequiredArgsConstructor -public class SlackController { - @Autowired - private final SlackService slackService; - - @Operation(summary = "슬랙 봇 설정", description = "슬랙 봇 추가 시 메시지를 전송하는 메서드입니다. ") - @GetMapping("/added") - public void addedSlackBot() { - slackService.sendMessage(":wave: Hi from a bot written in Alchive!"); - log.info("Slack Test"); - } - - @Operation(summary = "문제 리마인더", description = "30분마다 해결하지 못한 문제를 리마인드해주는 메서드입니다. ") - @GetMapping("/reminder") - public void sendReminder() { - slackService.sendMessageReminderBoard(); - } -} \ No newline at end of file diff --git a/src/main/java/com/Alchive/backend/slack/SlackService.java b/src/main/java/com/Alchive/backend/slack/SlackService.java deleted file mode 100644 index 45c1d95..0000000 --- a/src/main/java/com/Alchive/backend/slack/SlackService.java +++ /dev/null @@ -1,85 +0,0 @@ -package com.Alchive.backend.slack; - -import com.Alchive.backend.domain.board.Board; -import com.Alchive.backend.domain.board.BoardStatus; -import com.Alchive.backend.dto.request.BoardCreateRequest; -import com.Alchive.backend.dto.response.BoardResponseDTO; -import com.Alchive.backend.repository.BoardRepository; -import com.slack.api.Slack; -import com.slack.api.methods.MethodsClient; -import com.slack.api.methods.request.chat.ChatPostMessageRequest; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Configuration; -import org.springframework.scheduling.annotation.EnableScheduling; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Service; - -import java.time.LocalDateTime; -import java.time.temporal.ChronoUnit; - - -@Slf4j -@Service -@RequiredArgsConstructor -@EnableScheduling -@Configuration -public class SlackService { - @Value("${SLACK_TOKEN}") - private String token; - - private final BoardRepository boardRepository; - - private String channel = "#alchive-bot"; - - public void sendMessage(String message) { - try { - MethodsClient methods = Slack.getInstance().methods(token); - - ChatPostMessageRequest request = ChatPostMessageRequest.builder() - .channel(channel) - .text(message) - .build(); - - methods.chatPostMessage(request); - log.info("Slack - Message 전송 완료 : {}", message); - } catch (Exception e) { - log.error("Slack Error - {}", e.getMessage()); - } - } - - public void sendMessageCreateBoard(BoardCreateRequest boardCreateRequest, BoardResponseDTO board) { - String message = ""; - if (boardCreateRequest.getStatus() == BoardStatus.CORRECT) { - message = String.format(":partying_face: %d. %s 문제를 해결했습니다! \n \n", - boardCreateRequest.getProblemCreateRequest().getNumber(), - boardCreateRequest.getProblemCreateRequest().getTitle(), - board.getId(), board.getId()); - } else if (boardCreateRequest.getStatus() == BoardStatus.NOT_SUBMITTED) { - message = String.format(":round_pushpin: %d. %s 문제를 저장했습니다. \n \n리마인드 알림을 보내드릴게요! :saluting_face: \n \n<%s|:link: 문제 보러가기>", - boardCreateRequest.getProblemCreateRequest().getNumber(), - boardCreateRequest.getProblemCreateRequest().getTitle(), - boardCreateRequest.getProblemCreateRequest().getUrl()); - } else { - return; - } - sendMessage(message); - } - - @Scheduled(cron = "0 0 * * * *") // 정각마다 알림 - public void sendMessageReminderBoard() { - LocalDateTime threeDaysAgo = LocalDateTime.now().minusDays(1); - - Board unSolvedBoard = boardRepository.findUnsolvedBoardAddedBefore(threeDaysAgo); - - if (unSolvedBoard != null) { - String message = String.format(":star-struck: %d일 전 도전했던 %d. %s 문제를 아직 풀지 못했어요. \n \n다시 도전해보세요! :facepunch: \n \n<%s|:link: 문제 풀러가기>", - ChronoUnit.DAYS.between(unSolvedBoard.getCreatedAt(), LocalDateTime.now()), - unSolvedBoard.getProblem().getNumber(), - unSolvedBoard.getProblem().getTitle(), - unSolvedBoard.getProblem().getUrl()); - sendMessage(message); - } - } -} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8455354..ea16d00 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -38,4 +38,8 @@ management: endpoints: web: exposure: - include: "prometheus" \ No newline at end of file + include: "prometheus" + +discord: + bot: + token: ${DISCORD_BOT_TOKEN} \ No newline at end of file