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 index cb63a160..4df927c4 100644 --- a/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackAIService.java +++ b/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackAIService.java @@ -1,5 +1,9 @@ package gg.agit.konect.infrastructure.slack.ai; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -8,7 +12,6 @@ import gg.agit.konect.infrastructure.claude.client.ClaudeClient; import gg.agit.konect.infrastructure.slack.client.SlackClient; -import gg.agit.konect.infrastructure.slack.config.SlackProperties; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -19,10 +22,15 @@ 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 AI_RESPONSE_PREFIX = ":robot_face: *AI 응답*\n"; + private static final int MAX_HISTORY_MESSAGES = 10; + private static final String EMPTY_QUERY_MESSAGE = + "질문 내용이 비어있습니다. 예: `AI) 가입자 수 알려줘` 또는 `@봇이름 동아리 수는?`"; + private static final String ERROR_MESSAGE = + ":warning: 죄송합니다. 요청을 처리하는 중 오류가 발생했습니다."; private final ClaudeClient claudeClient; private final SlackClient slackClient; - private final SlackProperties slackProperties; public boolean isAIQuery(String text) { if (text == null) { @@ -53,37 +61,112 @@ public String normalizeAppMentionText(String text) { return MENTION_PATTERN.matcher(text).replaceFirst("").trim(); } + public List> fetchAIThreadReplies(String channelId, String threadTs) { + List> replies = slackClient.getThreadReplies(channelId, threadTs); + if (replies.isEmpty()) { + return new ArrayList<>(); + } + Map rootMessage = replies.get(0); + String rootText = (String)rootMessage.get("text"); + if (rootText != null && isAIQuery(rootText)) { + return replies; + } + if (replies.stream().anyMatch(r -> r.get("bot_id") != null)) { + return replies; + } + return new ArrayList<>(); + } + @Async - public void processAIQuery(String text) { + public void processAIQuery(String text, String channelId, String threadTs, + List> cachedReplies) { try { String userQuery = extractQuery(text); - // 빈 질문은 처리하지 않음 if (userQuery == null || userQuery.isBlank()) { log.debug("빈 질문으로 처리 중단"); - String guidanceMessage = formatSlackResponse( - "질문 내용이 비어있습니다. 예: `AI) 가입자 수 알려줘` 또는 `@봇이름 동아리 수는?`" - ); - slackClient.sendMessage(guidanceMessage, slackProperties.webhooks().event()); + slackClient.postThreadReply(channelId, threadTs, + formatSlackResponse(EMPTY_QUERY_MESSAGE)); return; } log.debug("AI 질문 처리 시작: {}", userQuery); - // ClaudeClient가 MCP를 통해 자동으로 SQL 결정 및 실행 - String response = claudeClient.chat(userQuery); + List> replies = + cachedReplies != null ? cachedReplies : new ArrayList<>(); + List> messages = buildConversationHistory(replies); + + if (messages.isEmpty()) { + messages = new ArrayList<>(); + messages.add(Map.of("role", "user", "content", userQuery)); + } + + String response = claudeClient.chat(messages); log.debug("AI 응답 생성 완료"); - // Slack에 응답 전송 - String slackMessage = formatSlackResponse(response); - slackClient.sendMessage(slackMessage, slackProperties.webhooks().event()); + slackClient.postThreadReply(channelId, threadTs, formatSlackResponse(response)); } catch (Exception e) { log.error("AI 질문 처리 중 오류 발생", e); - String errorMessage = ":warning: 죄송합니다. 요청을 처리하는 중 오류가 발생했습니다."; - slackClient.sendMessage(errorMessage, slackProperties.webhooks().event()); + slackClient.postThreadReply(channelId, threadTs, ERROR_MESSAGE); + } + } + + private List> buildConversationHistory(List> replies) { + if (replies.isEmpty()) { + return new ArrayList<>(); + } + + List> messages = new ArrayList<>(); + for (Map reply : replies) { + String replyText = (String)reply.get("text"); + + if (replyText == null) { + continue; + } + + if (reply.get("bot_id") != null) { + String content = replyText.startsWith(AI_RESPONSE_PREFIX) + ? replyText.substring(AI_RESPONSE_PREFIX.length()) + : replyText; + messages.add(Map.of("role", "assistant", "content", content)); + } else { + String normalizedText = normalizeAppMentionText(replyText); + String userText = isAIQuery(normalizedText) + ? extractQuery(normalizedText) + : normalizedText; + messages.add(Map.of("role", "user", "content", userText)); + } + } + + List> merged = mergeConsecutiveRoles(messages); + + if (!merged.isEmpty() && "assistant".equals(merged.get(0).get("role"))) { + merged = new ArrayList<>(merged.subList(1, merged.size())); + } + + if (merged.size() > MAX_HISTORY_MESSAGES) { + merged = new ArrayList<>( + merged.subList(merged.size() - MAX_HISTORY_MESSAGES, merged.size()) + ); + } + return merged; + } + + private List> mergeConsecutiveRoles(List> messages) { + List> merged = new ArrayList<>(); + for (Map msg : messages) { + if (!merged.isEmpty() + && merged.get(merged.size() - 1).get("role").equals(msg.get("role"))) { + Map last = new HashMap<>(merged.get(merged.size() - 1)); + last.put("content", last.get("content") + "\n" + msg.get("content")); + merged.set(merged.size() - 1, last); + } else { + merged.add(msg); + } } + return merged; } private String formatSlackResponse(String 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 index f9f441a2..be7fedbb 100644 --- a/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackEventController.java +++ b/src/main/java/gg/agit/konect/infrastructure/slack/ai/SlackEventController.java @@ -1,5 +1,6 @@ package gg.agit.konect.infrastructure.slack.ai; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @@ -32,7 +33,6 @@ public class SlackEventController { private static final String SLACK_SIGNATURE_HEADER = "X-Slack-Signature"; private static final int EVENT_CACHE_MAX_SIZE = 500; - // ConcurrentHashMap 기반 thread-safe event_id 캐시 private final Set processedEventIds = ConcurrentHashMap.newKeySet(); private final SlackAIService slackAIService; @@ -54,14 +54,12 @@ public ResponseEntity handleSlackEvent( 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(); @@ -69,14 +67,12 @@ public ResponseEntity handleSlackEvent( log.debug("Slack 이벤트 수신: type={}", type); - // 이벤트 콜백 처리 if ("event_callback".equals(type)) { String eventId = (String)payload.get("event_id"); if (eventId != null && !processedEventIds.add(eventId)) { log.debug("중복 이벤트 무시: event_id={}", eventId); return ResponseEntity.ok().build(); } - // 캐시 크기 초과 시 오래된 항목 제거 if (processedEventIds.size() > EVENT_CACHE_MAX_SIZE) { processedEventIds.remove(processedEventIds.iterator().next()); } @@ -86,7 +82,6 @@ public ResponseEntity handleSlackEvent( } } - // Slack은 3초 내 응답을 기대하므로 빠르게 200 반환 return ResponseEntity.ok().build(); } @@ -104,28 +99,37 @@ private void handleEvent(Map event) { String eventType = (String)event.get("type"); String text = (String)event.get("text"); String subtype = (String)event.get("subtype"); + String channelId = (String)event.get("channel"); + String ts = (String)event.get("ts"); + String threadTs = (String)event.get("thread_ts"); log.debug("이벤트 처리: eventType={}", eventType); - // bot 메시지나 변경 이벤트는 무시 if (subtype != null) { return; } - // 메시지 이벤트 처리 + String effectiveThreadTs = threadTs != null ? threadTs : ts; + if ("message".equals(eventType) && text != null) { if (slackAIService.isAIQuery(text)) { - // AI) prefix → 새 질문 또는 스레드 내 후속 질문 log.debug("AI 질문 감지"); - slackAIService.processAIQuery(text); + slackAIService.processAIQuery(text, channelId, effectiveThreadTs, null); + } else if (threadTs != null && slackAIService.isAppMention(text)) { + List> aiReplies = + slackAIService.fetchAIThreadReplies(channelId, threadTs); + if (!aiReplies.isEmpty()) { + log.debug("AI 스레드 내 후속 질문 감지"); + slackAIService.processAIQuery( + text, channelId, effectiveThreadTs, aiReplies); + } } } - // 앱 멘션 이벤트 처리 if ("app_mention".equals(eventType) && text != null) { String normalizedText = slackAIService.normalizeAppMentionText(text); log.debug("앱 멘션 감지"); - slackAIService.processAIQuery(normalizedText); + slackAIService.processAIQuery(normalizedText, channelId, effectiveThreadTs, null); } } } diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/client/SlackClient.java b/src/main/java/gg/agit/konect/infrastructure/slack/client/SlackClient.java index 25fc4c85..5f08f625 100644 --- a/src/main/java/gg/agit/konect/infrastructure/slack/client/SlackClient.java +++ b/src/main/java/gg/agit/konect/infrastructure/slack/client/SlackClient.java @@ -2,16 +2,22 @@ import static org.springframework.http.MediaType.APPLICATION_JSON; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; import org.springframework.retry.annotation.Recover; import org.springframework.retry.annotation.Retryable; import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; +import com.fasterxml.jackson.databind.ObjectMapper; + +import gg.agit.konect.infrastructure.slack.config.SlackProperties; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -20,7 +26,11 @@ @RequiredArgsConstructor public class SlackClient { + private static final String SLACK_API_BASE = "https://slack.com/api"; + private final RestTemplate restTemplate; + private final SlackProperties slackProperties; + private final ObjectMapper objectMapper; @Retryable public void sendMessage(String message, String url) { @@ -31,15 +41,82 @@ public void sendMessage(String message, String url) { payload.put("text", message); HttpEntity> request = new HttpEntity<>(payload, headers); - restTemplate.postForEntity( - url, - request, - String.class - ); + restTemplate.postForEntity(url, request, String.class); } @Recover public void sendMessageRecover(Exception e, String message, String url) { log.error("Slack 메시지 전송 실패 : message={}, url={}", message, url, e); } + + @Retryable + public void postThreadReply(String channelId, String threadTs, String text) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(APPLICATION_JSON); + headers.setBearerAuth(slackProperties.botToken()); + + Map payload = new HashMap<>(); + payload.put("channel", channelId); + payload.put("thread_ts", threadTs); + payload.put("text", text); + + HttpEntity> request = new HttpEntity<>(payload, headers); + ResponseEntity response = restTemplate.postForEntity( + SLACK_API_BASE + "/chat.postMessage", request, String.class + ); + + Map parsed = parseSlackResponse(response.getBody()); + Boolean ok = (Boolean)parsed.get("ok"); + if (!Boolean.TRUE.equals(ok)) { + String error = (String)parsed.get("error"); + log.error("Slack 스레드 응답 전송 실패: channelId={}, threadTs={}, error={}", + channelId, threadTs, error); + } + } + + @Recover + public void postThreadReplyRecover(Exception e, String channelId, + String threadTs, String text) { + log.error("Slack 스레드 응답 전송 최종 실패: channelId={}, threadTs={}", channelId, threadTs, e); + } + + @SuppressWarnings("unchecked") + public List> getThreadReplies(String channelId, String threadTs) { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(slackProperties.botToken()); + + HttpEntity request = new HttpEntity<>(headers); + String url = SLACK_API_BASE + "/conversations.replies?channel=" + channelId + + "&ts=" + threadTs; + + try { + ResponseEntity response = restTemplate.exchange( + url, org.springframework.http.HttpMethod.GET, request, String.class + ); + Map parsed = parseSlackResponse(response.getBody()); + Boolean ok = (Boolean)parsed.get("ok"); + if (!Boolean.TRUE.equals(ok)) { + String error = (String)parsed.get("error"); + log.error("스레드 이력 조회 실패 (Slack API): channelId={}, threadTs={}, error={}", + channelId, threadTs, error); + return new ArrayList<>(); + } + Object messages = parsed.get("messages"); + if (messages instanceof List) { + return (List>)messages; + } + } catch (Exception e) { + log.error("스레드 이력 조회 실패: channelId={}, threadTs={}", channelId, threadTs, e); + } + return new ArrayList<>(); + } + + private Map parseSlackResponse(String body) { + try { + return objectMapper.readValue(body, Map.class); + } catch (Exception e) { + log.error("Slack 응답 파싱 실패: {}", body, e); + return new HashMap<>(); + } + } } 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 05b8780d..3d986f32 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 @@ -5,7 +5,8 @@ @ConfigurationProperties(prefix = "slack") public record SlackProperties( Webhooks webhooks, - String signingSecret + String signingSecret, + String botToken ) { public record Webhooks( String error, diff --git a/src/main/resources/application-db.yml b/src/main/resources/application-db.yml index 94076fa7..4bc347d1 100644 --- a/src/main/resources/application-db.yml +++ b/src/main/resources/application-db.yml @@ -15,6 +15,8 @@ spring: hibernate: show_sql: false format_sql: true + jdbc: + time_zone: Asia/Seoul hibernate: ddl-auto: validate diff --git a/src/main/resources/application-infrastructure.yml b/src/main/resources/application-infrastructure.yml index dc28eacd..b44991e1 100644 --- a/src/main/resources/application-infrastructure.yml +++ b/src/main/resources/application-infrastructure.yml @@ -12,6 +12,7 @@ slack: error: ${SLACK_WEBHOOK_ERROR} event: ${SLACK_WEBHOOK_EVENT} signing-secret: ${SLACK_SIGNING_SECRET} + bot-token: ${SLACK_BOT_TOKEN} claude: api-key: ${CLAUDE_API_KEY}