diff --git a/.env.example b/.env.example index 4320a37f..a24f6592 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,12 @@ OAUTH_APPLE_KEY_ID=your-apple-key-id OAUTH_APPLE_PRIVATE_KEY_PATH=/path/to/apple/private/key.p8 OAUTH_APPLE_CLIENT_SECRET=your-apple-client-secret -# Slack Webhooks (Optional) +# Slack Configuration +SLACK_SIGNING_SECRET=your-slack-signing-secret SLACK_WEBHOOK_ERROR=https://hooks.slack.com/services/YOUR/WEBHOOK/URL SLACK_WEBHOOK_EVENT=https://hooks.slack.com/services/YOUR/WEBHOOK/URL + +# Gemini AI Configuration +GEMINI_PROJECT_ID=your-gcp-project-id +GEMINI_LOCATION=us-central1 +GEMINI_MODEL=gemini-1.5-flash diff --git a/build.gradle b/build.gradle index 926ff476..e2926cfe 100644 --- a/build.gradle +++ b/build.gradle @@ -70,6 +70,9 @@ dependencies { // notification implementation 'com.google.firebase:firebase-admin:9.2.0' + // Gemini AI + implementation 'com.google.cloud:google-cloud-vertexai:1.15.0' + // test testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' diff --git a/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java b/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java index 9aea18fa..42134e6a 100644 --- a/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java +++ b/src/main/java/gg/agit/konect/domain/club/repository/ClubMemberRepository.java @@ -190,4 +190,6 @@ long countByClubIdAndPosition( void deleteByUserId(Integer userId); + @Query("SELECT COUNT(cm) FROM ClubMember cm") + long countAll(); } diff --git a/src/main/java/gg/agit/konect/domain/club/repository/ClubRecruitmentRepository.java b/src/main/java/gg/agit/konect/domain/club/repository/ClubRecruitmentRepository.java index 181ec3cc..c326b314 100644 --- a/src/main/java/gg/agit/konect/domain/club/repository/ClubRecruitmentRepository.java +++ b/src/main/java/gg/agit/konect/domain/club/repository/ClubRecruitmentRepository.java @@ -30,4 +30,12 @@ default ClubRecruitment getByClubId(Integer clubId) { return findByClubId(clubId) .orElseThrow(() -> CustomException.of(NOT_FOUND_CLUB_RECRUITMENT)); } + + @Query(""" + SELECT COUNT(cr) + FROM ClubRecruitment cr + WHERE cr.isAlwaysRecruiting = true + OR (cr.startAt <= CURRENT_TIMESTAMP AND cr.endAt >= CURRENT_TIMESTAMP) + """) + long countCurrentlyRecruiting(); } diff --git a/src/main/java/gg/agit/konect/domain/club/repository/ClubRepository.java b/src/main/java/gg/agit/konect/domain/club/repository/ClubRepository.java index 55c605e5..3cee5893 100644 --- a/src/main/java/gg/agit/konect/domain/club/repository/ClubRepository.java +++ b/src/main/java/gg/agit/konect/domain/club/repository/ClubRepository.java @@ -29,4 +29,7 @@ default Club getById(Integer id) { List findAll(); Club save(Club club); + + @Query("SELECT COUNT(c) FROM Club c") + long countAll(); } diff --git a/src/main/java/gg/agit/konect/domain/user/repository/UserRepository.java b/src/main/java/gg/agit/konect/domain/user/repository/UserRepository.java index 7b06a292..e5c93a9a 100644 --- a/src/main/java/gg/agit/konect/domain/user/repository/UserRepository.java +++ b/src/main/java/gg/agit/konect/domain/user/repository/UserRepository.java @@ -143,4 +143,11 @@ List findUserIdsByUniversityAndStudentYear( AND u.deletedAt IS NULL """) List findAllByIdIn(@Param("ids") List ids); + + @Query(""" + SELECT COUNT(u) + FROM User u + WHERE u.deletedAt IS NULL + """) + long countActiveUsers(); } diff --git a/src/main/java/gg/agit/konect/global/config/SecurityPaths.java b/src/main/java/gg/agit/konect/global/config/SecurityPaths.java index 9ce92596..74dee66f 100644 --- a/src/main/java/gg/agit/konect/global/config/SecurityPaths.java +++ b/src/main/java/gg/agit/konect/global/config/SecurityPaths.java @@ -9,7 +9,8 @@ public final class SecurityPaths { "/swagger-ui.html", "/v3/api-docs/**", "/swagger-resources/**", - "/error" + "/error", + "/slack/events" }; public static final String[] DENY_PATHS = {}; diff --git a/src/main/java/gg/agit/konect/infrastructure/gemini/client/GeminiClient.java b/src/main/java/gg/agit/konect/infrastructure/gemini/client/GeminiClient.java new file mode 100644 index 00000000..f7059df1 --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/gemini/client/GeminiClient.java @@ -0,0 +1,121 @@ +package gg.agit.konect.infrastructure.gemini.client; + +import java.io.IOException; + +import org.springframework.stereotype.Component; + +import com.google.cloud.vertexai.VertexAI; +import com.google.cloud.vertexai.api.GenerateContentResponse; +import com.google.cloud.vertexai.generativeai.GenerativeModel; +import com.google.cloud.vertexai.generativeai.ResponseHandler; + +import gg.agit.konect.infrastructure.gemini.config.GeminiProperties; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class GeminiClient { + + private static final String INTENT_ANALYSIS_PROMPT = """ + 당신은 KONECT 서비스의 데이터 분석 AI입니다. + 사용자의 질문을 분석하여 다음 중 하나의 쿼리 타입만 반환하세요. + 반드시 아래 목록 중 하나의 값만 반환하고, 다른 텍스트는 포함하지 마세요. + + 가능한 쿼리 타입: + - USER_COUNT: 가입된 사용자 수, 회원 수, 유저 수 관련 질문 + - CLUB_COUNT: 전체 동아리 수, 동아리 개수 관련 질문 + - CLUB_RECRUITING_COUNT: 현재 모집 중인 동아리 수, 모집 현황 관련 질문 + - CLUB_MEMBER_TOTAL_COUNT: 전체 동아리원 수, 동아리 멤버 총 인원 관련 질문 + - UNKNOWN: 위 항목에 해당하지 않는 질문 + + 예시: + - "가입된 사용자 수 알려줘" -> USER_COUNT + - "현재 모집 중인 동아리 몇개야?" -> CLUB_RECRUITING_COUNT + - "전체 동아리 수는?" -> CLUB_COUNT + - "동아리원이 총 몇명이야?" -> CLUB_MEMBER_TOTAL_COUNT + - "오늘 날씨 어때?" -> UNKNOWN + - "특정 사용자 이메일 알려줘" -> UNKNOWN + + 사용자 질문: %s + + 쿼리 타입: + """; + + private static final String RESPONSE_GENERATION_PROMPT = """ + 당신은 KONECT 서비스의 친절한 AI 어시스턴트입니다. + 아래 정보를 바탕으로 사용자에게 자연스럽고 친절한 한국어로 응답해주세요. + 이모지를 적절히 사용하여 친근하게 답변해주세요. + 응답은 간결하게 2-3문장으로 작성해주세요. + + 사용자 질문: %s + 조회된 데이터: %s + + 응답: + """; + + private final GeminiProperties geminiProperties; + private VertexAI vertexAI; + private GenerativeModel generativeModel; + + public GeminiClient(GeminiProperties geminiProperties) { + this.geminiProperties = geminiProperties; + } + + @PostConstruct + public void init() { + this.vertexAI = new VertexAI( + geminiProperties.projectId(), + geminiProperties.location() + ); + this.generativeModel = new GenerativeModel(geminiProperties.model(), vertexAI); + log.info("GeminiClient 초기화 완료: project={}, location={}, model={}", + geminiProperties.projectId(), + geminiProperties.location(), + geminiProperties.model() + ); + } + + @PreDestroy + public void destroy() { + if (vertexAI != null) { + try { + vertexAI.close(); + log.info("GeminiClient 리소스 해제 완료"); + } catch (Exception e) { + log.warn("GeminiClient 리소스 해제 중 오류", e); + } + } + } + + public String analyzeIntent(String userQuery) { + String prompt = String.format(INTENT_ANALYSIS_PROMPT, userQuery); + String result = callGemini(prompt); + if (result == null) { + return "UNKNOWN"; + } + return result.trim().toUpperCase(); + } + + public String generateResponse(String userQuery, String data) { + String prompt = String.format(RESPONSE_GENERATION_PROMPT, userQuery, data); + String result = callGemini(prompt); + return result != null ? result : "응답을 생성할 수 없습니다."; + } + + private String callGemini(String prompt) { + if (generativeModel == null) { + log.error("GenerativeModel이 초기화되지 않았습니다."); + return null; + } + + try { + GenerateContentResponse response = generativeModel.generateContent(prompt); + return ResponseHandler.getText(response); + } catch (IOException e) { + log.error("Gemini API 호출 실패", e); + return null; + } + } +} diff --git a/src/main/java/gg/agit/konect/infrastructure/gemini/config/GeminiProperties.java b/src/main/java/gg/agit/konect/infrastructure/gemini/config/GeminiProperties.java new file mode 100644 index 00000000..3cc553f8 --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/gemini/config/GeminiProperties.java @@ -0,0 +1,12 @@ +package gg.agit.konect.infrastructure.gemini.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "gemini") +public record GeminiProperties( + String projectId, + String location, + String model +) { + +} diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackAIService.java b/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackAIService.java new file mode 100644 index 00000000..9f997d3b --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackAIService.java @@ -0,0 +1,113 @@ +package gg.agit.konect.infrastructure.slack.ai; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import gg.agit.konect.infrastructure.gemini.client.GeminiClient; +import gg.agit.konect.infrastructure.slack.client.SlackClient; +import gg.agit.konect.infrastructure.slack.config.SlackProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SlackAIService { + + private static final Pattern AI_PREFIX_PATTERN = Pattern.compile("^[Aa][Ii]\\)\\s*(.+)$"); + private static final Pattern MENTION_PATTERN = Pattern.compile("^<@[^>]+>\\s*"); + private static final String UNKNOWN = "UNKNOWN"; + + private final GeminiClient geminiClient; + private final StatisticsQueryExecutor queryExecutor; + private final SlackClient slackClient; + private final SlackProperties slackProperties; + + public boolean isAIQuery(String text) { + if (text == null) { + return false; + } + return AI_PREFIX_PATTERN.matcher(text.trim()).matches(); + } + + public String extractQuery(String text) { + Matcher matcher = AI_PREFIX_PATTERN.matcher(text.trim()); + if (matcher.matches()) { + return matcher.group(1).trim(); + } + return text; + } + + public String normalizeAppMentionText(String text) { + if (text == null) { + return null; + } + return MENTION_PATTERN.matcher(text).replaceFirst("").trim(); + } + + @Async + public void processAIQuery(String text) { + try { + String userQuery = extractQuery(text); + + // 빈 질문은 처리하지 않음 + if (userQuery == null || userQuery.isBlank()) { + log.debug("빈 질문으로 처리 중단"); + String guidanceMessage = formatSlackResponse( + "질문 내용이 비어있습니다. 예: `AI) 가입자 수 알려줘` 또는 `@봇이름 동아리 수는?`" + ); + slackClient.sendMessage(guidanceMessage, slackProperties.webhooks().event()); + return; + } + + log.debug("AI 질문 처리 시작"); + + // 1. Gemini에게 의도 분석 요청 + String queryType = geminiClient.analyzeIntent(userQuery); + log.debug("분석된 쿼리 타입: {}", queryType); + + String response; + + // 2. 지원하지 않는 질문인 경우 + if (UNKNOWN.equals(queryType)) { + response = generateUnsupportedResponse(userQuery); + } else { + // 3. 안전한 통계 쿼리 실행 + String data = queryExecutor.execute(queryType); + + if (data == null) { + response = generateUnsupportedResponse(userQuery); + } else { + // 4. Gemini에게 자연어 응답 생성 요청 + response = geminiClient.generateResponse(userQuery, data); + } + } + + log.debug("AI 응답 생성 완료"); + + // 5. Slack에 응답 전송 + String slackMessage = formatSlackResponse(response); + slackClient.sendMessage(slackMessage, slackProperties.webhooks().event()); + + } catch (Exception e) { + log.error("AI 질문 처리 중 오류 발생", e); + String errorMessage = ":warning: 죄송합니다. 요청을 처리하는 중 오류가 발생했습니다."; + slackClient.sendMessage(errorMessage, slackProperties.webhooks().event()); + } + } + + private String generateUnsupportedResponse(String userQuery) { + return geminiClient.generateResponse( + userQuery, + "이 질문은 현재 지원하지 않는 유형입니다. " + + "사용자 수, 동아리 수, 모집 현황, 동아리원 수 등의 통계 질문을 해주세요." + ); + } + + private String formatSlackResponse(String response) { + return String.format(":robot_face: *AI 응답*\n%s", response); + } +} diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackEventController.java b/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackEventController.java new file mode 100644 index 00000000..67ad3d47 --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackEventController.java @@ -0,0 +1,113 @@ +package gg.agit.konect.infrastructure.slack.ai; + +import java.util.Map; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import gg.agit.konect.infrastructure.slack.config.SlackSignatureVerifier; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RestController +@RequestMapping("/slack/events") +@RequiredArgsConstructor +public class SlackEventController { + + private static final String SLACK_TIMESTAMP_HEADER = "X-Slack-Request-Timestamp"; + private static final String SLACK_SIGNATURE_HEADER = "X-Slack-Signature"; + + private final SlackAIService slackAIService; + private final SlackSignatureVerifier signatureVerifier; + private final ObjectMapper objectMapper; + + @PostMapping + @SuppressWarnings("unchecked") + public ResponseEntity handleSlackEvent( + @RequestHeader(value = SLACK_TIMESTAMP_HEADER, required = false) String timestamp, + @RequestHeader(value = SLACK_SIGNATURE_HEADER, required = false) String signature, + @RequestBody String rawBody + ) { + Map payload = parsePayload(rawBody); + if (payload == null) { + log.warn("Slack 요청 본문 파싱 실패"); + return ResponseEntity.badRequest().build(); + } + + String type = (String)payload.get("type"); + + // URL 검증은 서명 검증 없이 처리 (최초 설정 시) + if ("url_verification".equals(type)) { + String challenge = (String)payload.get("challenge"); + log.info("Slack URL 검증 요청 처리"); + return ResponseEntity.ok(Map.of("challenge", challenge)); + } + + // 서명 검증 - 원본 요청 본문 사용 + if (!signatureVerifier.isValidRequest(timestamp, signature, rawBody)) { + log.warn("Slack 서명 검증 실패"); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + log.debug("Slack 이벤트 수신: type={}", type); + + // 이벤트 콜백 처리 + if ("event_callback".equals(type)) { + Map event = (Map)payload.get("event"); + if (event != null) { + handleEvent(event); + } + } + + // Slack은 3초 내 응답을 기대하므로 빠르게 200 반환 + return ResponseEntity.ok().build(); + } + + private Map parsePayload(String rawBody) { + try { + return objectMapper.readValue(rawBody, new TypeReference>() { + }); + } catch (JsonProcessingException e) { + log.error("JSON 파싱 실패", e); + return null; + } + } + + private void handleEvent(Map event) { + String eventType = (String)event.get("type"); + String text = (String)event.get("text"); + String subtype = (String)event.get("subtype"); + + log.debug("이벤트 처리: eventType={}", eventType); + + // bot 메시지나 변경 이벤트는 무시 + if (subtype != null) { + return; + } + + // 메시지 이벤트 처리 + if ("message".equals(eventType) && text != null) { + if (slackAIService.isAIQuery(text)) { + log.debug("AI 질문 감지"); + slackAIService.processAIQuery(text); + } + } + + // 앱 멘션 이벤트 처리 + if ("app_mention".equals(eventType) && text != null) { + String normalizedText = slackAIService.normalizeAppMentionText(text); + log.debug("앱 멘션 감지"); + slackAIService.processAIQuery(normalizedText); + } + } +} diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/ai/StatisticsQueryExecutor.java b/src/main/java/gg/agit/konect/infrastructure/slack/ai/StatisticsQueryExecutor.java new file mode 100644 index 00000000..941d5420 --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/slack/ai/StatisticsQueryExecutor.java @@ -0,0 +1,53 @@ +package gg.agit.konect.infrastructure.slack.ai; + +import org.springframework.stereotype.Component; + +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubRecruitmentRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class StatisticsQueryExecutor { + + private final UserRepository userRepository; + private final ClubRepository clubRepository; + private final ClubRecruitmentRepository clubRecruitmentRepository; + private final ClubMemberRepository clubMemberRepository; + + public String execute(String queryType) { + log.debug("통계 쿼리 실행: queryType={}", queryType); + + return switch (queryType) { + case "USER_COUNT" -> executeUserCount(); + case "CLUB_COUNT" -> executeClubCount(); + case "CLUB_RECRUITING_COUNT" -> executeClubRecruitingCount(); + case "CLUB_MEMBER_TOTAL_COUNT" -> executeClubMemberTotalCount(); + default -> null; + }; + } + + private String executeUserCount() { + long count = userRepository.countActiveUsers(); + return String.format("현재 가입된 활성 사용자 수: %d명", count); + } + + private String executeClubCount() { + long count = clubRepository.countAll(); + return String.format("전체 동아리 수: %d개", count); + } + + private String executeClubRecruitingCount() { + long count = clubRecruitmentRepository.countCurrentlyRecruiting(); + return String.format("현재 모집 중인 동아리 수: %d개", count); + } + + private String executeClubMemberTotalCount() { + long count = clubMemberRepository.countAll(); + return String.format("전체 동아리원 수: %d명", count); + } +} diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/config/SlackProperties.java b/src/main/java/gg/agit/konect/infrastructure/slack/config/SlackProperties.java index e66b97cc..05b8780d 100644 --- a/src/main/java/gg/agit/konect/infrastructure/slack/config/SlackProperties.java +++ b/src/main/java/gg/agit/konect/infrastructure/slack/config/SlackProperties.java @@ -4,7 +4,8 @@ @ConfigurationProperties(prefix = "slack") public record SlackProperties( - Webhooks webhooks + Webhooks webhooks, + String signingSecret ) { public record Webhooks( String error, diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/config/SlackSignatureVerifier.java b/src/main/java/gg/agit/konect/infrastructure/slack/config/SlackSignatureVerifier.java new file mode 100644 index 00000000..ae41e6c6 --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/slack/config/SlackSignatureVerifier.java @@ -0,0 +1,121 @@ +package gg.agit.konect.infrastructure.slack.config; + +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SlackSignatureVerifier { + + private static final String HMAC_SHA256 = "HmacSHA256"; + private static final String VERSION = "v0"; + private static final String VERSION_PREFIX = VERSION + "="; + private static final long TIMESTAMP_TOLERANCE_SECONDS = 300; + private static final long MILLIS_TO_SECONDS = 1000L; + private static final int HEX_RADIX = 16; + + private final SlackProperties slackProperties; + + public boolean isValidRequest(String timestamp, String signature, String requestBody) { + if (timestamp == null || signature == null || requestBody == null) { + log.warn("Slack 서명 검증 실패: 필수 헤더 누락"); + return false; + } + + // 타임스탬프 검증 (5분 이내) + if (!isTimestampValid(timestamp)) { + log.warn("Slack 서명 검증 실패: 타임스탬프 만료"); + return false; + } + + // 서명 검증 (constant-time comparison) + byte[] expectedHash = calculateSignatureBytes(timestamp, requestBody); + if (expectedHash == null) { + return false; + } + + byte[] providedHash = parseSignature(signature); + if (providedHash == null) { + log.warn("Slack 서명 검증 실패: 서명 형식 오류"); + return false; + } + + boolean isValid = MessageDigest.isEqual(expectedHash, providedHash); + + if (!isValid) { + log.warn("Slack 서명 검증 실패: 서명 불일치"); + } + + return isValid; + } + + private boolean isTimestampValid(String timestamp) { + try { + long requestTime = Long.parseLong(timestamp); + long currentTime = System.currentTimeMillis() / MILLIS_TO_SECONDS; + return Math.abs(currentTime - requestTime) <= TIMESTAMP_TOLERANCE_SECONDS; + } catch (NumberFormatException e) { + return false; + } + } + + private byte[] calculateSignatureBytes(String timestamp, String requestBody) { + String signingSecret = slackProperties.signingSecret(); + if (signingSecret == null || signingSecret.isBlank()) { + log.error("Slack signing secret이 설정되지 않았습니다."); + return null; + } + + String baseString = VERSION + ":" + timestamp + ":" + requestBody; + + try { + Mac mac = Mac.getInstance(HMAC_SHA256); + SecretKeySpec secretKey = new SecretKeySpec( + signingSecret.getBytes(StandardCharsets.UTF_8), + HMAC_SHA256 + ); + mac.init(secretKey); + return mac.doFinal(baseString.getBytes(StandardCharsets.UTF_8)); + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + log.error("Slack 서명 계산 실패", e); + return null; + } + } + + private byte[] parseSignature(String signature) { + if (!signature.startsWith(VERSION_PREFIX)) { + return null; + } + + String hexPart = signature.substring(VERSION_PREFIX.length()); + return hexToBytes(hexPart); + } + + private byte[] hexToBytes(String hex) { + if (hex.length() % 2 != 0) { + return null; + } + + try { + byte[] bytes = new byte[hex.length() / 2]; + for (int i = 0; i < bytes.length; i++) { + int index = i * 2; + bytes[i] = (byte)Integer.parseInt(hex.substring(index, index + 2), HEX_RADIX); + } + return bytes; + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/src/main/resources/application-infrastructure.yml b/src/main/resources/application-infrastructure.yml index 30320ca7..310869bd 100644 --- a/src/main/resources/application-infrastructure.yml +++ b/src/main/resources/application-infrastructure.yml @@ -11,3 +11,9 @@ slack: webhooks: error: ${SLACK_WEBHOOK_ERROR} event: ${SLACK_WEBHOOK_EVENT} + signing-secret: ${SLACK_SIGNING_SECRET} + +gemini: + project-id: ${GEMINI_PROJECT_ID} + location: ${GEMINI_LOCATION:us-central1} + model: ${GEMINI_MODEL:gemini-1.5-flash}