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
1 change: 0 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Map<String, Object>> initialMessages) {
try {
List<Map<String, Object>> messages = new ArrayList<>(initialMessages);
List<Map<String, Object>> messages = new ArrayList<>();
messages.add(Map.of("role", "user", "content", userMessage));

for (int i = 0; i < MAX_TOOL_ITERATIONS; i++) {
Map<String, Object> request = buildRequest(messages);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

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

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

/**
* AI 스레드이면 replies 반환, 아니면 빈 리스트 반환.
* Controller에서 isAIThread 판단과 getThreadReplies를 한 번에 처리하도록 통합.
*/
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, String channelId, String threadTs,
List<Map<String, Object>> 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<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);
// 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<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));
}
}

return mergeConsecutiveRoles(messages);
}

/**
* Claude API는 user/assistant이 교대로 와야 하므로
* 연속된 동일 role 메시지를 하나로 병합.
*/
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);
}
String errorMessage = ":warning: 죄송합니다. 요청을 처리하는 중 오류가 발생했습니다.";
slackClient.sendMessage(errorMessage, slackProperties.webhooks().event());
}
return merged;
}

private String formatSlackResponse(String response) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package gg.agit.konect.infrastructure.slack.ai;

import java.util.List;
import java.util.Map;

import org.springframework.http.HttpStatus;
Expand Down Expand Up @@ -90,9 +89,6 @@ 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);

Expand All @@ -101,31 +97,19 @@ private void handleEvent(Map<String, Object> 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<Map<String, Object>> aiReplies =
slackAIService.fetchAIThreadReplies(channelId, threadTs);
if (!aiReplies.isEmpty()) {
log.debug("AI 스레드 내 후속 질문 감지");
slackAIService.processAIQuery(
text, channelId, effectiveThreadTs, aiReplies);
}
slackAIService.processAIQuery(text);
}
}

// 앱 멘션 이벤트 처리
if ("app_mention".equals(eventType) && text != null) {
String normalizedText = slackAIService.normalizeAppMentionText(text);
log.debug("앱 멘션 감지");
slackAIService.processAIQuery(normalizedText, channelId, effectiveThreadTs, null);
slackAIService.processAIQuery(normalizedText);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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) {
Expand All @@ -43,58 +31,15 @@ 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);
}

public void postThreadReply(String channelId, String threadTs, String message) {
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", message);

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

String url = CONVERSATIONS_REPLIES_URL + "?channel=" + channelId + "&ts=" + threadTs;
HttpEntity<Void> request = new HttpEntity<>(headers);

try {
String responseBody = restTemplate.exchange(url, HttpMethod.GET, request, String.class).getBody();

if (responseBody == null) {
return new ArrayList<>();
}

Map<String, Object> 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<>();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@
@ConfigurationProperties(prefix = "slack")
public record SlackProperties(
Webhooks webhooks,
String signingSecret,
String botToken
String signingSecret
) {
public record Webhooks(
String error,
Expand Down
2 changes: 0 additions & 2 deletions src/main/resources/application-db.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@ spring:
hibernate:
show_sql: false
format_sql: true
jdbc:
time_zone: Asia/Seoul
hibernate:
ddl-auto: validate

Expand Down
1 change: 0 additions & 1 deletion src/main/resources/application-infrastructure.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Loading