Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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;

Expand All @@ -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) {
Expand Down Expand Up @@ -53,37 +61,112 @@ public String normalizeAppMentionText(String text) {
return MENTION_PATTERN.matcher(text).replaceFirst("").trim();
}

public List<Map<String, Object>> fetchAIThreadReplies(String channelId, String threadTs) {
List<Map<String, Object>> replies = slackClient.getThreadReplies(channelId, threadTs);
if (replies.isEmpty()) {
return new ArrayList<>();
}
Map<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> replies =
cachedReplies != null ? cachedReplies : new ArrayList<>();
List<Map<String, Object>> 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<Map<String, Object>> buildConversationHistory(List<Map<String, Object>> replies) {
if (replies.isEmpty()) {
return new ArrayList<>();
}

List<Map<String, Object>> messages = new ArrayList<>();
for (Map<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> mergeConsecutiveRoles(List<Map<String, Object>> messages) {
List<Map<String, Object>> merged = new ArrayList<>();
for (Map<String, Object> msg : messages) {
if (!merged.isEmpty()
&& merged.get(merged.size() - 1).get("role").equals(msg.get("role"))) {
Map<String, Object> 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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<String> processedEventIds = ConcurrentHashMap.newKeySet();

private final SlackAIService slackAIService;
Expand All @@ -54,29 +54,25 @@ public ResponseEntity<Object> 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();
}

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());
}
Expand All @@ -86,7 +82,6 @@ public ResponseEntity<Object> handleSlackEvent(
}
}

// Slack은 3초 내 응답을 기대하므로 빠르게 200 반환
return ResponseEntity.ok().build();
}

Expand All @@ -104,28 +99,37 @@ private void handleEvent(Map<String, Object> 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<Map<String, Object>> 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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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) {
Expand All @@ -31,15 +41,82 @@ public void sendMessage(String message, String url) {
payload.put("text", message);

HttpEntity<Map<String, Object>> 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<String, Object> payload = new HashMap<>();
payload.put("channel", channelId);
payload.put("thread_ts", threadTs);
payload.put("text", text);

HttpEntity<Map<String, Object>> request = new HttpEntity<>(payload, headers);
ResponseEntity<String> response = restTemplate.postForEntity(
SLACK_API_BASE + "/chat.postMessage", request, String.class
);

Map<String, Object> 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<Map<String, Object>> getThreadReplies(String channelId, String threadTs) {
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(slackProperties.botToken());

HttpEntity<Void> request = new HttpEntity<>(headers);
String url = SLACK_API_BASE + "/conversations.replies?channel=" + channelId
+ "&ts=" + threadTs;

try {
ResponseEntity<String> response = restTemplate.exchange(
url, org.springframework.http.HttpMethod.GET, request, String.class
);
Map<String, Object> 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<Map<String, Object>>)messages;
}
} catch (Exception e) {
log.error("스레드 이력 조회 실패: channelId={}, threadTs={}", channelId, threadTs, e);
}
return new ArrayList<>();
}

private Map<String, Object> parseSlackResponse(String body) {
try {
return objectMapper.readValue(body, Map.class);
} catch (Exception e) {
log.error("Slack 응답 파싱 실패: {}", body, e);
return new HashMap<>();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
@ConfigurationProperties(prefix = "slack")
public record SlackProperties(
Webhooks webhooks,
String signingSecret
String signingSecret,
String botToken
) {
public record Webhooks(
String error,
Expand Down
2 changes: 2 additions & 0 deletions src/main/resources/application-db.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ spring:
hibernate:
show_sql: false
format_sql: true
jdbc:
time_zone: Asia/Seoul
hibernate:
ddl-auto: validate

Expand Down
Loading
Loading