From c9106c000e2e1693ea551e825ff91531756f8c62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Wed, 11 Mar 2026 18:31:52 +0900 Subject: [PATCH 1/5] =?UTF-8?q?Revert=20"refactor:=20Slack=20=EB=82=B4=20A?= =?UTF-8?q?I=20=EA=B4=80=EB=A0=A8=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= =?UTF-8?q?=20(#376)"=20(#379)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit c13d602d9b4763962c50c65ccc98ce37182090a8. --- .env.example | 1 - .../claude/client/ClaudeClient.java | 14 +-- .../slack/ai/SlackAIService.java | 109 +++--------------- .../slack/ai/SlackEventController.java | 20 +--- .../slack/client/SlackClient.java | 65 +---------- .../slack/config/SlackProperties.java | 3 +- src/main/resources/application-db.yml | 2 - .../resources/application-infrastructure.yml | 1 - 8 files changed, 25 insertions(+), 190 deletions(-) diff --git a/.env.example b/.env.example index 7935c79b..576c1aac 100644 --- a/.env.example +++ b/.env.example @@ -49,7 +49,6 @@ OAUTH_APPLE_PRIVATE_KEY_PATH=/path/to/apple/private/key.p8 # Slack Configuration SLACK_SIGNING_SECRET=your-slack-signing-secret -SLACK_BOT_TOKEN=xoxb-your-bot-token-here SLACK_WEBHOOK_ERROR=https://hooks.slack.com/services/YOUR/WEBHOOK/URL SLACK_WEBHOOK_EVENT=https://hooks.slack.com/services/YOUR/WEBHOOK/URL diff --git a/src/main/java/gg/agit/konect/infrastructure/claude/client/ClaudeClient.java b/src/main/java/gg/agit/konect/infrastructure/claude/client/ClaudeClient.java index 329f66b3..04613127 100644 --- a/src/main/java/gg/agit/konect/infrastructure/claude/client/ClaudeClient.java +++ b/src/main/java/gg/agit/konect/infrastructure/claude/client/ClaudeClient.java @@ -130,19 +130,9 @@ public ClaudeClient(RestClient.Builder restClientBuilder, * @return AI response */ public String chat(String userMessage) { - return chat(List.of(Map.of("role", "user", "content", userMessage))); - } - - /** - * Process conversation history with tool use support. - * Supports multi-turn tool calls for schema discovery and query execution. - * - * @param initialMessages Conversation history - * @return AI response - */ - public String chat(List> initialMessages) { try { - List> messages = new ArrayList<>(initialMessages); + List> messages = new ArrayList<>(); + messages.add(Map.of("role", "user", "content", userMessage)); for (int i = 0; i < MAX_TOOL_ITERATIONS; i++) { Map request = buildRequest(messages); 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 cc4621be..35b0439b 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,9 +1,5 @@ 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; @@ -12,6 +8,7 @@ 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; @@ -22,14 +19,10 @@ 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 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,109 +46,37 @@ public String normalizeAppMentionText(String text) { return MENTION_PATTERN.matcher(text).replaceFirst("").trim(); } - /** - * AI 스레드이면 replies 반환, 아니면 빈 리스트 반환. - * Controller에서 isAIThread 판단과 getThreadReplies를 한 번에 처리하도록 통합. - */ - 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, String channelId, String threadTs, - List> cachedReplies) { + public void processAIQuery(String text) { try { String userQuery = extractQuery(text); + // 빈 질문은 처리하지 않음 if (userQuery == null || userQuery.isBlank()) { log.debug("빈 질문으로 처리 중단"); - slackClient.postThreadReply(channelId, threadTs, - formatSlackResponse(EMPTY_QUERY_MESSAGE)); + String guidanceMessage = formatSlackResponse( + "질문 내용이 비어있습니다. 예: `AI) 가입자 수 알려줘` 또는 `@봇이름 동아리 수는?`" + ); + slackClient.sendMessage(guidanceMessage, slackProperties.webhooks().event()); return; } log.debug("AI 질문 처리 시작: {}", 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); + // ClaudeClient가 MCP를 통해 자동으로 SQL 결정 및 실행 + String response = claudeClient.chat(userQuery); log.debug("AI 응답 생성 완료"); - slackClient.postThreadReply(channelId, threadTs, formatSlackResponse(response)); + // Slack에 응답 전송 + String slackMessage = formatSlackResponse(response); + slackClient.sendMessage(slackMessage, slackProperties.webhooks().event()); } catch (Exception e) { log.error("AI 질문 처리 중 오류 발생", e); - 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)); - } - } - - return mergeConsecutiveRoles(messages); - } - - /** - * Claude API는 user/assistant이 교대로 와야 하므로 - * 연속된 동일 role 메시지를 하나로 병합. - */ - 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); - } + String errorMessage = ":warning: 죄송합니다. 요청을 처리하는 중 오류가 발생했습니다."; + slackClient.sendMessage(errorMessage, slackProperties.webhooks().event()); } - 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 496f6d41..4fe92b80 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,6 +1,5 @@ package gg.agit.konect.infrastructure.slack.ai; -import java.util.List; import java.util.Map; import org.springframework.http.HttpStatus; @@ -90,9 +89,6 @@ 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); @@ -101,23 +97,11 @@ private void handleEvent(Map event) { return; } - // thread_ts가 있으면 스레드 내 메시지, 없으면 새 스레드 시작 - String effectiveThreadTs = threadTs != null ? threadTs : ts; - // 메시지 이벤트 처리 if ("message".equals(eventType) && text != null) { if (slackAIService.isAIQuery(text)) { log.debug("AI 질문 감지"); - slackAIService.processAIQuery(text, channelId, effectiveThreadTs, null); - } else if (threadTs != null) { - // AI 스레드 확인과 replies fetch를 한 번에 처리 (중복 API 호출 방지) - List> aiReplies = - slackAIService.fetchAIThreadReplies(channelId, threadTs); - if (!aiReplies.isEmpty()) { - log.debug("AI 스레드 내 후속 질문 감지"); - slackAIService.processAIQuery( - text, channelId, effectiveThreadTs, aiReplies); - } + slackAIService.processAIQuery(text); } } @@ -125,7 +109,7 @@ private void handleEvent(Map event) { if ("app_mention".equals(eventType) && text != null) { String normalizedText = slackAIService.normalizeAppMentionText(text); log.debug("앱 멘션 감지"); - slackAIService.processAIQuery(normalizedText, channelId, effectiveThreadTs, null); + slackAIService.processAIQuery(normalizedText); } } } 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 9f186f08..25fc4c85 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,23 +2,16 @@ 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.HttpMethod; 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.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; - -import gg.agit.konect.infrastructure.slack.config.SlackProperties; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -27,12 +20,7 @@ @RequiredArgsConstructor public class SlackClient { - private static final String CHAT_POST_MESSAGE_URL = "https://slack.com/api/chat.postMessage"; - private static final String CONVERSATIONS_REPLIES_URL = "https://slack.com/api/conversations.replies"; - private final RestTemplate restTemplate; - private final SlackProperties slackProperties; - private final ObjectMapper objectMapper; @Retryable public void sendMessage(String message, String url) { @@ -43,58 +31,15 @@ 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); } - - public void postThreadReply(String channelId, String threadTs, String message) { - 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", message); - - HttpEntity> request = new HttpEntity<>(payload, headers); - try { - restTemplate.postForEntity(CHAT_POST_MESSAGE_URL, request, String.class); - } catch (Exception e) { - log.error("Slack 스레드 답글 전송 실패: channel={}, threadTs={}", channelId, threadTs, e); - } - } - - @SuppressWarnings("unchecked") - public List> getThreadReplies(String channelId, String threadTs) { - HttpHeaders headers = new HttpHeaders(); - headers.setBearerAuth(slackProperties.botToken()); - - String url = CONVERSATIONS_REPLIES_URL + "?channel=" + channelId + "&ts=" + threadTs; - HttpEntity request = new HttpEntity<>(headers); - - try { - String responseBody = restTemplate.exchange(url, HttpMethod.GET, request, String.class).getBody(); - - if (responseBody == null) { - return new ArrayList<>(); - } - - Map parsed = objectMapper.readValue(responseBody, new TypeReference<>() { }); - Object messages = parsed.get("messages"); - - if (messages == null) { - return new ArrayList<>(); - } - - return objectMapper.convertValue(messages, new TypeReference<>() { }); - } catch (Exception e) { - log.error("Slack 스레드 이력 조회 실패: channel={}, threadTs={}", channelId, threadTs, e); - return new ArrayList<>(); - } - } } 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 3d986f32..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 @@ -5,8 +5,7 @@ @ConfigurationProperties(prefix = "slack") public record SlackProperties( Webhooks webhooks, - String signingSecret, - String botToken + String signingSecret ) { public record Webhooks( String error, diff --git a/src/main/resources/application-db.yml b/src/main/resources/application-db.yml index 4bc347d1..94076fa7 100644 --- a/src/main/resources/application-db.yml +++ b/src/main/resources/application-db.yml @@ -15,8 +15,6 @@ 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 b44991e1..dc28eacd 100644 --- a/src/main/resources/application-infrastructure.yml +++ b/src/main/resources/application-infrastructure.yml @@ -12,7 +12,6 @@ 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} From 77601beb16f9e3538cd258f10ada08464c10fe29 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Thu, 12 Mar 2026 15:02:59 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20Slack=20AI=20429=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EC=8A=A4=EB=A0=88?= =?UTF-8?q?=EB=93=9C=20=EB=8C=80=EB=8C=93=EA=B8=80=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../claude/client/ClaudeClient.java | 59 +++++++-- .../slack/ai/SlackAIService.java | 115 +++++++++++++++--- .../slack/ai/SlackEventController.java | 38 +++++- .../slack/client/SlackClient.java | 54 +++++++- .../slack/config/SlackProperties.java | 3 +- src/main/resources/application-db.yml | 4 + .../resources/application-infrastructure.yml | 1 + 7 files changed, 244 insertions(+), 30 deletions(-) diff --git a/src/main/java/gg/agit/konect/infrastructure/claude/client/ClaudeClient.java b/src/main/java/gg/agit/konect/infrastructure/claude/client/ClaudeClient.java index 04613127..9b40508c 100644 --- a/src/main/java/gg/agit/konect/infrastructure/claude/client/ClaudeClient.java +++ b/src/main/java/gg/agit/konect/infrastructure/claude/client/ClaudeClient.java @@ -122,13 +122,58 @@ public ClaudeClient(RestClient.Builder restClientBuilder, this.objectMapper = objectMapper; } - /** - * Process user query with tool use support. - * Supports multi-turn tool calls for schema discovery and query execution. - * - * @param userMessage User's question - * @return AI response - */ + public String chat(List> initialMessages) { + try { + List> messages = new ArrayList<>(initialMessages); + + for (int i = 0; i < MAX_TOOL_ITERATIONS; i++) { + Map request = buildRequest(messages); + String response = callClaudeApi(request); + + if (response == null) { + log.error("Claude API returned null response"); + throw new ClaudeException("Claude API returned null response", null); + } + + JsonNode responseNode = objectMapper.readTree(response); + String stopReason = responseNode.path("stop_reason").asText(); + JsonNode content = responseNode.path("content"); + + if ("end_turn".equals(stopReason)) { + return extractTextResponse(content); + } + + if ("tool_use".equals(stopReason)) { + List> toolResults = processToolCalls(content); + + if (toolResults.isEmpty()) { + log.warn("stop_reason is tool_use but no tool_use blocks found"); + return extractTextResponse(content); + } + + messages.add(Map.of( + "role", "assistant", + "content", objectMapper.convertValue(content, List.class) + )); + messages.add(Map.of( + "role", "user", + "content", toolResults + )); + } else { + log.warn("Unexpected stop_reason: {}", stopReason); + return extractTextResponse(content); + } + } + + log.warn("Maximum tool iterations reached"); + throw new ClaudeException("Maximum tool iterations reached", null); + + } catch (JsonProcessingException e) { + log.error("Failed to parse Claude API response", e); + throw new ClaudeException("Failed to parse Claude API response", e); + } + } + public String chat(String userMessage) { try { List> messages = new ArrayList<>(); 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 35b0439b..5eb41c1e 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) { @@ -46,37 +54,114 @@ public String normalizeAppMentionText(String text) { return MENTION_PATTERN.matcher(text).replaceFirst("").trim(); } + /** + * AI 스레드이면 replies 반환, 아니면 빈 리스트 반환. + * Controller에서 isAIThread 판단과 getThreadReplies를 한 번에 처리하도록 통합. + */ + 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); + // 토큰 과다 사용 방지: 최근 MAX_HISTORY_MESSAGES개만 유지 + if (merged.size() > MAX_HISTORY_MESSAGES) { + merged = merged.subList(merged.size() - MAX_HISTORY_MESSAGES, merged.size()); + } + return merged; + } + + /** + * Claude API는 user/assistant이 교대로 와야 하므로 + * 연속된 동일 role 메시지를 하나로 병합. + */ + 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 4fe92b80..17a57827 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,6 +1,9 @@ package gg.agit.konect.infrastructure.slack.ai; +import java.util.Collections; +import java.util.List; import java.util.Map; +import java.util.Set; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -28,6 +31,17 @@ 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 static final int EVENT_CACHE_MAX_SIZE = 500; + + // Slack 재시도 중복 방지용 event_id 캐시 + private final Set processedEventIds = Collections.newSetFromMap( + new java.util.LinkedHashMap<>() { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > EVENT_CACHE_MAX_SIZE; + } + } + ); private final SlackAIService slackAIService; private final SlackSignatureVerifier signatureVerifier; @@ -65,6 +79,11 @@ public ResponseEntity handleSlackEvent( // 이벤트 콜백 처리 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(); + } Map event = (Map)payload.get("event"); if (event != null) { handleEvent(event); @@ -89,6 +108,9 @@ 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); @@ -97,11 +119,23 @@ private void handleEvent(Map event) { return; } + // thread_ts가 있으면 스레드 내 메시지, 없으면 새 스레드 시작 + String effectiveThreadTs = threadTs != null ? threadTs : ts; + // 메시지 이벤트 처리 if ("message".equals(eventType) && text != null) { if (slackAIService.isAIQuery(text)) { log.debug("AI 질문 감지"); - slackAIService.processAIQuery(text); + slackAIService.processAIQuery(text, channelId, effectiveThreadTs, null); + } else if (threadTs != null) { + // AI 스레드 확인과 replies fetch를 한 번에 처리 (중복 API 호출 방지) + List> aiReplies = + slackAIService.fetchAIThreadReplies(channelId, threadTs); + if (!aiReplies.isEmpty()) { + log.debug("AI 스레드 내 후속 질문 감지"); + slackAIService.processAIQuery( + text, channelId, effectiveThreadTs, aiReplies); + } } } @@ -109,7 +143,7 @@ private void handleEvent(Map event) { 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..deed9679 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,49 @@ 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); } + + 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); + restTemplate.postForEntity(SLACK_API_BASE + "/chat.postMessage", request, String.class); + } + + @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 = objectMapper.readValue(response.getBody(), Map.class); + 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<>(); + } } 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..03b38433 100644 --- a/src/main/resources/application-db.yml +++ b/src/main/resources/application-db.yml @@ -17,6 +17,10 @@ spring: format_sql: true hibernate: ddl-auto: validate + properties: + hibernate: + jdbc: + time_zone: Asia/Seoul data: redis: 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} From 218930142bcb0a39c434eab6cb27723d1bc84809 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Thu, 12 Mar 2026 15:24:01 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20=EB=8C=80=EB=8C=93=EA=B8=80=EC=97=90?= =?UTF-8?q?=EB=8F=84=20'ai)'=20=EB=A5=BC=20=ED=86=B5=ED=95=B4=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=EB=90=A0=20=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../slack/ai/SlackAIService.java | 25 ++++++++----- .../slack/ai/SlackEventController.java | 23 +++++------- .../slack/client/SlackClient.java | 37 ++++++++++++++++++- src/main/resources/application-db.yml | 6 +-- 4 files changed, 63 insertions(+), 28 deletions(-) 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 5eb41c1e..b156fac2 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 @@ -39,6 +39,13 @@ public boolean isAIQuery(String text) { return AI_PREFIX_PATTERN.matcher(text.trim()).matches(); } + public boolean isAppMention(String text) { + if (text == null) { + return false; + } + return MENTION_PATTERN.matcher(text.trim()).find(); + } + public String extractQuery(String text) { Matcher matcher = AI_PREFIX_PATTERN.matcher(text.trim()); if (matcher.matches()) { @@ -54,10 +61,6 @@ public String normalizeAppMentionText(String text) { return MENTION_PATTERN.matcher(text).replaceFirst("").trim(); } - /** - * AI 스레드이면 replies 반환, 아니면 빈 리스트 반환. - * Controller에서 isAIThread 판단과 getThreadReplies를 한 번에 처리하도록 통합. - */ public List> fetchAIThreadReplies(String channelId, String threadTs) { List> replies = slackClient.getThreadReplies(channelId, threadTs); if (replies.isEmpty()) { @@ -138,17 +141,21 @@ private List> buildConversationHistory(List> merged = mergeConsecutiveRoles(messages); + + // Claude API는 반드시 user role로 시작해야 함 + if (!merged.isEmpty() && "assistant".equals(merged.get(0).get("role"))) { + merged = new ArrayList<>(merged.subList(1, merged.size())); + } + // 토큰 과다 사용 방지: 최근 MAX_HISTORY_MESSAGES개만 유지 if (merged.size() > MAX_HISTORY_MESSAGES) { - merged = merged.subList(merged.size() - MAX_HISTORY_MESSAGES, merged.size()); + merged = new ArrayList<>( + merged.subList(merged.size() - MAX_HISTORY_MESSAGES, merged.size()) + ); } return merged; } - /** - * Claude API는 user/assistant이 교대로 와야 하므로 - * 연속된 동일 role 메시지를 하나로 병합. - */ private List> mergeConsecutiveRoles(List> messages) { List> merged = new ArrayList<>(); for (Map msg : messages) { 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 17a57827..e15795ab 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,9 +1,9 @@ package gg.agit.konect.infrastructure.slack.ai; -import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -33,15 +33,8 @@ public class SlackEventController { private static final String SLACK_SIGNATURE_HEADER = "X-Slack-Signature"; private static final int EVENT_CACHE_MAX_SIZE = 500; - // Slack 재시도 중복 방지용 event_id 캐시 - private final Set processedEventIds = Collections.newSetFromMap( - new java.util.LinkedHashMap<>() { - @Override - protected boolean removeEldestEntry(Map.Entry eldest) { - return size() > EVENT_CACHE_MAX_SIZE; - } - } - ); + // ConcurrentHashMap 기반 thread-safe event_id 캐시 + private final Set processedEventIds = ConcurrentHashMap.newKeySet(); private final SlackAIService slackAIService; private final SlackSignatureVerifier signatureVerifier; @@ -84,6 +77,10 @@ public ResponseEntity handleSlackEvent( log.debug("중복 이벤트 무시: event_id={}", eventId); return ResponseEntity.ok().build(); } + // 캐시 크기 초과 시 오래된 항목 제거 + if (processedEventIds.size() > EVENT_CACHE_MAX_SIZE) { + processedEventIds.remove(processedEventIds.iterator().next()); + } Map event = (Map)payload.get("event"); if (event != null) { handleEvent(event); @@ -119,16 +116,16 @@ private void handleEvent(Map event) { return; } - // thread_ts가 있으면 스레드 내 메시지, 없으면 새 스레드 시작 String effectiveThreadTs = threadTs != null ? threadTs : ts; // 메시지 이벤트 처리 if ("message".equals(eventType) && text != null) { if (slackAIService.isAIQuery(text)) { + // AI) prefix → 새 질문 또는 스레드 내 후속 질문 log.debug("AI 질문 감지"); slackAIService.processAIQuery(text, channelId, effectiveThreadTs, null); - } else if (threadTs != null) { - // AI 스레드 확인과 replies fetch를 한 번에 처리 (중복 API 호출 방지) + } else if (threadTs != null && slackAIService.isAppMention(text)) { + // 스레드 내 @멘션 있을 때만 이력 포함 처리 List> aiReplies = slackAIService.fetchAIThreadReplies(channelId, threadTs); if (!aiReplies.isEmpty()) { 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 deed9679..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 @@ -49,6 +49,7 @@ 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); @@ -60,7 +61,23 @@ public void postThreadReply(String channelId, String threadTs, String text) { payload.put("text", text); HttpEntity> request = new HttpEntity<>(payload, headers); - restTemplate.postForEntity(SLACK_API_BASE + "/chat.postMessage", request, String.class); + 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") @@ -76,7 +93,14 @@ public List> getThreadReplies(String channelId, String threa ResponseEntity response = restTemplate.exchange( url, org.springframework.http.HttpMethod.GET, request, String.class ); - Map parsed = objectMapper.readValue(response.getBody(), Map.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; @@ -86,4 +110,13 @@ public List> getThreadReplies(String channelId, String threa } 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/resources/application-db.yml b/src/main/resources/application-db.yml index 03b38433..4bc347d1 100644 --- a/src/main/resources/application-db.yml +++ b/src/main/resources/application-db.yml @@ -15,12 +15,10 @@ spring: hibernate: show_sql: false format_sql: true - hibernate: - ddl-auto: validate - properties: - hibernate: jdbc: time_zone: Asia/Seoul + hibernate: + ddl-auto: validate data: redis: From 4d0ebaf67a94d9134a92b7c7e9604a593f219030 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Thu, 12 Mar 2026 16:15:40 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20ClaudeClient=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?chat=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../claude/client/ClaudeClient.java | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/src/main/java/gg/agit/konect/infrastructure/claude/client/ClaudeClient.java b/src/main/java/gg/agit/konect/infrastructure/claude/client/ClaudeClient.java index 5ac6ec2c..11594abe 100644 --- a/src/main/java/gg/agit/konect/infrastructure/claude/client/ClaudeClient.java +++ b/src/main/java/gg/agit/konect/infrastructure/claude/client/ClaudeClient.java @@ -122,17 +122,9 @@ public ClaudeClient(RestClient.Builder restClientBuilder, this.objectMapper = objectMapper; } - /** - * Process user query with tool use support. - * Supports multi-turn tool calls for schema discovery and query execution. - * - * @param userMessage User's question - * @return AI response - */ - public String chat(String userMessage) { + public String chat(List> initialMessages) { try { - List> messages = new ArrayList<>(); - messages.add(Map.of("role", "user", "content", userMessage)); + List> messages = new ArrayList<>(initialMessages); for (int i = 0; i < MAX_TOOL_ITERATIONS; i++) { Map request = buildRequest(messages); @@ -197,7 +189,6 @@ public String chat(String userMessage) { } JsonNode responseNode = objectMapper.readTree(response); - String stopReason = responseNode.path("stop_reason").asText(); JsonNode content = responseNode.path("content"); @@ -206,7 +197,6 @@ public String chat(String userMessage) { } if ("tool_use".equals(stopReason)) { - // Process tool calls and add results List> toolResults = processToolCalls(content); if (toolResults.isEmpty()) { @@ -214,18 +204,15 @@ public String chat(String userMessage) { return extractTextResponse(content); } - // Add assistant's response to messages messages.add(Map.of( "role", "assistant", "content", objectMapper.convertValue(content, List.class) )); - messages.add(Map.of( "role", "user", "content", toolResults )); } else { - // Unexpected stop reason log.warn("Unexpected stop_reason: {}", stopReason); return extractTextResponse(content); } From ef282cc7207fd6a28fed5c67248f8ad977277c53 Mon Sep 17 00:00:00 2001 From: JanooGwan <103417427+JanooGwan@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:29:18 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20Slack=20AI=20=EC=8A=A4=EB=A0=88?= =?UTF-8?q?=EB=93=9C=20=EB=8C=93=EA=B8=80=20=EB=B0=8F=20=EB=8C=80=ED=99=94?= =?UTF-8?q?=20=EC=9D=B4=EB=A0=A5=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../slack/ai/SlackAIService.java | 113 +++++++++++++++--- .../slack/ai/SlackEventController.java | 28 +++-- .../slack/client/SlackClient.java | 87 +++++++++++++- .../slack/config/SlackProperties.java | 3 +- src/main/resources/application-db.yml | 2 + .../resources/application-infrastructure.yml | 1 + 6 files changed, 201 insertions(+), 33 deletions(-) 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}