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}