choavis-agent 1.4.0 → 1.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +3 -2
- package/README.md +120 -205
- package/dist/agent/adapters/claude.d.ts +13 -0
- package/dist/agent/adapters/claude.d.ts.map +1 -0
- package/dist/agent/adapters/claude.js +199 -0
- package/dist/agent/adapters/claude.js.map +1 -0
- package/dist/agent/protocol.d.ts +56 -0
- package/dist/agent/protocol.d.ts.map +1 -0
- package/dist/agent/protocol.js +9 -0
- package/dist/agent/protocol.js.map +1 -0
- package/dist/agent/queue.d.ts.map +1 -1
- package/dist/agent/queue.js +4 -2
- package/dist/agent/queue.js.map +1 -1
- package/dist/agent/runner.d.ts +8 -0
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +13 -39
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/session.d.ts +27 -1
- package/dist/agent/session.d.ts.map +1 -1
- package/dist/agent/session.js +79 -3
- package/dist/agent/session.js.map +1 -1
- package/dist/agent/usage.d.ts +8 -5
- package/dist/agent/usage.d.ts.map +1 -1
- package/dist/agent/usage.js +21 -0
- package/dist/agent/usage.js.map +1 -1
- package/dist/approval/server.d.ts +11 -1
- package/dist/approval/server.d.ts.map +1 -1
- package/dist/approval/server.js +46 -4
- package/dist/approval/server.js.map +1 -1
- package/dist/approval/setup.d.ts +5 -0
- package/dist/approval/setup.d.ts.map +1 -1
- package/dist/approval/setup.js +31 -0
- package/dist/approval/setup.js.map +1 -1
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js +18 -8
- package/dist/cli/config.js.map +1 -1
- package/dist/cli/onboard.d.ts.map +1 -1
- package/dist/cli/onboard.js +299 -51
- package/dist/cli/onboard.js.map +1 -1
- package/dist/cli.js +10 -5
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +5 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +9 -1
- package/dist/config.js.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/slack/commands/compact.js +2 -2
- package/dist/slack/commands/compact.js.map +1 -1
- package/dist/slack/commands/help.d.ts.map +1 -1
- package/dist/slack/commands/help.js +16 -11
- package/dist/slack/commands/help.js.map +1 -1
- package/dist/slack/commands/index.d.ts.map +1 -1
- package/dist/slack/commands/index.js +26 -0
- package/dist/slack/commands/index.js.map +1 -1
- package/dist/slack/commands/inject.d.ts +3 -0
- package/dist/slack/commands/inject.d.ts.map +1 -0
- package/dist/slack/commands/inject.js +44 -0
- package/dist/slack/commands/inject.js.map +1 -0
- package/dist/slack/commands/memory.js +7 -7
- package/dist/slack/commands/memory.js.map +1 -1
- package/dist/slack/commands/model.d.ts +3 -0
- package/dist/slack/commands/model.d.ts.map +1 -0
- package/dist/slack/commands/model.js +43 -0
- package/dist/slack/commands/model.js.map +1 -0
- package/dist/slack/commands/new.js +1 -1
- package/dist/slack/commands/new.js.map +1 -1
- package/dist/slack/commands/permission.d.ts +3 -0
- package/dist/slack/commands/permission.d.ts.map +1 -0
- package/dist/slack/commands/permission.js +51 -0
- package/dist/slack/commands/permission.js.map +1 -0
- package/dist/slack/commands/project.js +2 -2
- package/dist/slack/commands/project.js.map +1 -1
- package/dist/slack/commands/resume.d.ts +3 -0
- package/dist/slack/commands/resume.d.ts.map +1 -0
- package/dist/slack/commands/resume.js +93 -0
- package/dist/slack/commands/resume.js.map +1 -0
- package/dist/slack/commands/session-info.d.ts +3 -0
- package/dist/slack/commands/session-info.d.ts.map +1 -0
- package/dist/slack/commands/session-info.js +33 -0
- package/dist/slack/commands/session-info.js.map +1 -0
- package/dist/slack/commands/sessions.d.ts +3 -0
- package/dist/slack/commands/sessions.d.ts.map +1 -0
- package/dist/slack/commands/sessions.js +54 -0
- package/dist/slack/commands/sessions.js.map +1 -0
- package/dist/slack/commands/status.d.ts.map +1 -1
- package/dist/slack/commands/status.js +7 -2
- package/dist/slack/commands/status.js.map +1 -1
- package/dist/slack/commands/usage.d.ts +5 -0
- package/dist/slack/commands/usage.d.ts.map +1 -1
- package/dist/slack/commands/usage.js +93 -12
- package/dist/slack/commands/usage.js.map +1 -1
- package/dist/slack/context.d.ts +11 -8
- package/dist/slack/context.d.ts.map +1 -1
- package/dist/slack/context.js +36 -14
- package/dist/slack/context.js.map +1 -1
- package/dist/slack/file-downloader.d.ts +18 -0
- package/dist/slack/file-downloader.d.ts.map +1 -0
- package/dist/slack/file-downloader.js +196 -0
- package/dist/slack/file-downloader.js.map +1 -0
- package/dist/slack/formatter.d.ts +0 -4
- package/dist/slack/formatter.d.ts.map +1 -1
- package/dist/slack/formatter.js +0 -8
- package/dist/slack/formatter.js.map +1 -1
- package/dist/slack/handlers.d.ts +3 -4
- package/dist/slack/handlers.d.ts.map +1 -1
- package/dist/slack/handlers.js +372 -259
- package/dist/slack/handlers.js.map +1 -1
- package/dist/slack/link-resolver.d.ts +24 -0
- package/dist/slack/link-resolver.d.ts.map +1 -0
- package/dist/slack/link-resolver.js +52 -0
- package/dist/slack/link-resolver.js.map +1 -0
- package/dist/slack/middleware.d.ts +17 -0
- package/dist/slack/middleware.d.ts.map +1 -0
- package/dist/slack/middleware.js +73 -0
- package/dist/slack/middleware.js.map +1 -0
- package/dist/slack/slack-retry.d.ts +18 -0
- package/dist/slack/slack-retry.d.ts.map +1 -0
- package/dist/slack/slack-retry.js +71 -0
- package/dist/slack/slack-retry.js.map +1 -0
- package/dist/slack/tool-formatter.d.ts +18 -0
- package/dist/slack/tool-formatter.d.ts.map +1 -0
- package/dist/slack/tool-formatter.js +154 -0
- package/dist/slack/tool-formatter.js.map +1 -0
- package/dist/slack/types.d.ts +22 -0
- package/dist/slack/types.d.ts.map +1 -0
- package/dist/slack/types.js +2 -0
- package/dist/slack/types.js.map +1 -0
- package/package.json +10 -10
- package/scripts/pretool-hook.mjs +0 -0
- package/slack-app-manifest.json +12 -1
package/dist/slack/handlers.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { Assistant } from '@slack/bolt';
|
|
2
|
+
import { runAgent, runSession } from '../agent/runner.js';
|
|
3
|
+
import { getSession, setSession, clearSession, getWorkDir, setWorkDir, isBotStarted, markBotStarted } from '../agent/session.js';
|
|
3
4
|
import { enqueue, getQueueSize } from '../agent/queue.js';
|
|
4
5
|
import { formatResponse, splitMessage } from './formatter.js';
|
|
6
|
+
import { resolveSessionFromLink } from './link-resolver.js';
|
|
5
7
|
import { config } from '../config.js';
|
|
6
8
|
import { createLogger } from '../utils/logger.js';
|
|
7
9
|
import { matchCommand, executeCommand, registerAllCommands } from './commands/index.js';
|
|
@@ -9,83 +11,78 @@ import { recordUsage } from '../agent/usage.js';
|
|
|
9
11
|
import { buildMemoryContext } from '../agent/memory.js';
|
|
10
12
|
import { fetchThreadContext, fetchChannelHistory } from './context.js';
|
|
11
13
|
import { ApprovalServer } from '../approval/server.js';
|
|
12
|
-
import { installHook } from '../approval/setup.js';
|
|
14
|
+
import { installHook, addToolToSettings } from '../approval/setup.js';
|
|
15
|
+
import { withRetry, bestEffort } from './slack-retry.js';
|
|
16
|
+
import { formatToolInline } from './tool-formatter.js';
|
|
17
|
+
import { isBootMessage, isDuplicate, isAllowedUser, trackMentionThread, mentionStartedThreads, getActiveAgentCount, getMaxConcurrent, incrementActiveCount, decrementActiveCount, setActiveKill, deleteActiveKill, getActiveKill, setTypingStatus, } from './middleware.js';
|
|
18
|
+
import { downloadAndPrepareFiles } from './file-downloader.js';
|
|
13
19
|
// 모듈 로드 시 1회 등록
|
|
14
20
|
registerAllCommands();
|
|
15
21
|
const log = createLogger('handler');
|
|
16
22
|
const STREAM_UPDATE_INTERVAL_MS = 3000;
|
|
17
|
-
//
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const allowedUsers = process.env.ALLOWED_SLACK_USERS?.split(',').map(s => s.trim()) || [];
|
|
22
|
-
// 동시 프로세스 제한
|
|
23
|
-
const MAX_CONCURRENT = parseInt(process.env.MAX_CONCURRENT_AGENTS || '5', 10);
|
|
24
|
-
let activeAgentCount = 0;
|
|
25
|
-
export function getActiveAgentCount() { return activeAgentCount; }
|
|
26
|
-
export function getMaxConcurrent() { return MAX_CONCURRENT; }
|
|
27
|
-
// 실행 중인 에이전트 프로세스의 kill 함수 추적
|
|
28
|
-
const activeKills = new Map();
|
|
29
|
-
export function getActiveKill(threadTs) {
|
|
30
|
-
return activeKills.get(threadTs);
|
|
23
|
+
// 활성 승인 서버 추적 (채널/스레드별)
|
|
24
|
+
const activeApprovalServers = new Map();
|
|
25
|
+
function getApprovalKey(channel, threadTs) {
|
|
26
|
+
return `${channel}:${threadTs}`;
|
|
31
27
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
channel_id: channel,
|
|
36
|
-
thread_ts: threadTs,
|
|
37
|
-
status,
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
catch {
|
|
41
|
-
// Slack Assistant API 미지원 시 조용히 무시
|
|
42
|
-
}
|
|
28
|
+
// inject 명령어에서 직접 호출 (중복방지/부팅체크 건너뛰기 — 사용자 인증은 이미 완료됨)
|
|
29
|
+
export async function handleIncomingDirect(text, channel, threadTs, messageTs, client, userId) {
|
|
30
|
+
await handleIncoming(text, channel, threadTs, messageTs, client, userId, undefined, undefined, true);
|
|
43
31
|
}
|
|
44
|
-
async function handleIncoming(text, channel, threadTs, messageTs, client, userId, slackContext) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
32
|
+
async function handleIncoming(text, channel, threadTs, messageTs, client, userId, slackContext, files, bypassGuards = false) {
|
|
33
|
+
if (!bypassGuards) {
|
|
34
|
+
// 앱 시작 이전 이벤트 무시 (재시작 시 밀린 이벤트 방지)
|
|
35
|
+
if (isBootMessage(messageTs)) {
|
|
36
|
+
log.info('부팅 이전 메시지 무시', { messageTs });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
// 이벤트 중복 처리 방지
|
|
40
|
+
if (isDuplicate(messageTs)) {
|
|
41
|
+
log.info('중복 메시지 무시', { messageTs });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
// 허용된 사용자 확인 (미설정 시 전체 거부)
|
|
45
|
+
if (!isAllowedUser(userId)) {
|
|
46
|
+
log.warn('허용되지 않은 사용자', { userId });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
56
49
|
}
|
|
57
50
|
log.info('메시지 수신', { text: text.slice(0, 50), threadTs });
|
|
58
51
|
// 커맨드 디스패처
|
|
59
52
|
const cmdMatch = matchCommand(text);
|
|
60
53
|
if (cmdMatch) {
|
|
54
|
+
const activeAgentCount = getActiveAgentCount();
|
|
55
|
+
const MAX_CONCURRENT = getMaxConcurrent();
|
|
61
56
|
await executeCommand(cmdMatch, { text, channel, threadTs, messageTs, client, userId, activeAgentCount, maxConcurrent: MAX_CONCURRENT, getActiveKill });
|
|
62
57
|
return;
|
|
63
58
|
}
|
|
59
|
+
// Slack 링크에서 세션 자동 연결 (현재 스레드에 세션이 없을 때만)
|
|
60
|
+
if (!getSession(threadTs)) {
|
|
61
|
+
const linked = resolveSessionFromLink(text);
|
|
62
|
+
if (linked) {
|
|
63
|
+
setSession(threadTs, linked.sessionId);
|
|
64
|
+
if (linked.workDir) {
|
|
65
|
+
setWorkDir(threadTs, linked.workDir);
|
|
66
|
+
setSession(threadTs, linked.sessionId);
|
|
67
|
+
}
|
|
68
|
+
log.info('링크에서 세션 자동 연결', { threadTs, linkedSessionId: linked.sessionId });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
64
71
|
// 동시 프로세스 제한
|
|
72
|
+
const activeAgentCount = getActiveAgentCount();
|
|
73
|
+
const MAX_CONCURRENT = getMaxConcurrent();
|
|
65
74
|
if (activeAgentCount >= MAX_CONCURRENT) {
|
|
66
75
|
log.warn('동시 프로세스 제한 초과', { activeAgentCount, MAX_CONCURRENT });
|
|
67
|
-
await client.chat.postMessage({
|
|
68
|
-
channel,
|
|
69
|
-
thread_ts: threadTs,
|
|
70
|
-
text: ':warning: 현재 처리량이 많습니다. 잠시 후 다시 시도해주세요.',
|
|
71
|
-
}).catch(() => { });
|
|
76
|
+
await withRetry(() => client.chat.postMessage({ channel, thread_ts: threadTs, text: ':warning: 현재 처리량이 많습니다. 잠시 후 다시 시도해주세요.' }), 'chat.postMessage(limit)').catch(() => { });
|
|
72
77
|
return;
|
|
73
78
|
}
|
|
74
|
-
// 큐 대기 중이면 안내
|
|
75
79
|
const queueSize = getQueueSize(threadTs);
|
|
76
80
|
if (queueSize > 0) {
|
|
77
81
|
log.info('큐 대기 중', { threadTs, queueSize });
|
|
78
82
|
}
|
|
79
|
-
activeAgentCount++;
|
|
80
83
|
await enqueue(threadTs, async () => {
|
|
84
|
+
incrementActiveCount();
|
|
81
85
|
const startTime = Date.now();
|
|
82
|
-
// 1. 처리 중 리액션
|
|
83
|
-
await client.reactions.add({
|
|
84
|
-
channel,
|
|
85
|
-
name: 'hourglass_flowing_sand',
|
|
86
|
-
timestamp: messageTs,
|
|
87
|
-
}).catch(() => { });
|
|
88
|
-
// 2. 응답 메시지 ts (실제 응답이 올 때 lazy 생성)
|
|
89
86
|
let responseTs;
|
|
90
87
|
await setTypingStatus(client, channel, threadTs, '처리 중...');
|
|
91
88
|
// 3. AbortController로 타임아웃 관리
|
|
@@ -101,10 +98,20 @@ async function handleIncoming(text, channel, threadTs, messageTs, client, userId
|
|
|
101
98
|
const threadWorkDir = getWorkDir(threadTs) || config.agent.workDir;
|
|
102
99
|
// 메모리 + Slack 컨텍스트를 프롬프트에 주입
|
|
103
100
|
const memoryContext = await buildMemoryContext(config.memory.dataDir);
|
|
104
|
-
|
|
101
|
+
// 첨부 파일 처리
|
|
102
|
+
let fileContext = '';
|
|
103
|
+
if (files && files.length > 0) {
|
|
104
|
+
const fileResult = await downloadAndPrepareFiles(files, threadTs, config.slack.botToken, client);
|
|
105
|
+
fileContext = fileResult.promptSection;
|
|
106
|
+
if (fileResult.errors.length > 0) {
|
|
107
|
+
const errorMsg = fileResult.errors.map(e => `:warning: ${e}`).join('\n');
|
|
108
|
+
await bestEffort(() => client.chat.postMessage({ channel, thread_ts: threadTs, text: errorMsg }), 'chat.postMessage(file-errors)');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const promptParts = [memoryContext, slackContext, fileContext, text].filter(Boolean);
|
|
105
112
|
const fullPrompt = promptParts.join('\n\n---\n\n');
|
|
106
113
|
// 승인 서버 설정 (bypassPermissions가 아닌 경우)
|
|
107
|
-
const permMode = config.agent.permissionMode
|
|
114
|
+
const permMode = config.agent.permissionMode;
|
|
108
115
|
let approvalPort;
|
|
109
116
|
if (permMode !== 'bypassPermissions') {
|
|
110
117
|
approvalServer = new ApprovalServer();
|
|
@@ -113,18 +120,17 @@ async function handleIncoming(text, channel, threadTs, messageTs, client, userId
|
|
|
113
120
|
});
|
|
114
121
|
approvalPort = await approvalServer.start();
|
|
115
122
|
cleanupHook = await installHook(threadWorkDir, approvalPort);
|
|
116
|
-
// action 핸들러에서 찾을 수 있도록 등록
|
|
117
123
|
const approvalKey = getApprovalKey(channel, threadTs);
|
|
118
124
|
activeApprovalServers.set(approvalKey, approvalServer);
|
|
119
125
|
}
|
|
120
|
-
const { stream, kill } =
|
|
126
|
+
const { stream, kill } = runSession({
|
|
121
127
|
prompt: fullPrompt,
|
|
122
128
|
sessionId: sessionId || undefined,
|
|
123
129
|
workDir: threadWorkDir,
|
|
124
130
|
abortSignal: abortController.signal,
|
|
125
131
|
approvalPort,
|
|
126
132
|
});
|
|
127
|
-
|
|
133
|
+
setActiveKill(threadTs, kill);
|
|
128
134
|
let responseText = '';
|
|
129
135
|
let newSessionId;
|
|
130
136
|
let toolUseCount = 0;
|
|
@@ -138,122 +144,70 @@ async function handleIncoming(text, channel, threadTs, messageTs, client, userId
|
|
|
138
144
|
aborted = true;
|
|
139
145
|
break;
|
|
140
146
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
// content_block_delta에서 부분 텍스트
|
|
153
|
-
if (event.type === 'content_block_delta' && event.delta?.text) {
|
|
154
|
-
responseText += event.delta.text;
|
|
155
|
-
}
|
|
156
|
-
// result에서 최종 텍스트
|
|
157
|
-
if (event.type === 'result') {
|
|
158
|
-
if (event.result) {
|
|
159
|
-
responseText = event.result;
|
|
160
|
-
}
|
|
161
|
-
if (event.session_id) {
|
|
162
|
-
newSessionId = event.session_id;
|
|
163
|
-
}
|
|
164
|
-
// 사용량 기록 (result 이벤트의 usage 필드가 있으면)
|
|
165
|
-
if (event.usage) {
|
|
166
|
-
void recordUsage({
|
|
167
|
-
userId: userId || 'unknown',
|
|
168
|
-
threadTs,
|
|
169
|
-
timestamp: Date.now(),
|
|
170
|
-
model: event.model || 'unknown',
|
|
171
|
-
inputTokens: event.usage.input_tokens || 0,
|
|
172
|
-
outputTokens: event.usage.output_tokens || 0,
|
|
173
|
-
durationMs: Date.now() - startTime,
|
|
174
|
-
}, config.usage.dataDir);
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
// 도구 사용 카운트
|
|
178
|
-
if (event.type === 'tool_use') {
|
|
179
|
-
toolUseCount++;
|
|
180
|
-
}
|
|
181
|
-
// 에러 이벤트 처리 (에러 유형별 자동 복구)
|
|
182
|
-
if (event.type === 'error') {
|
|
183
|
-
const errContent = event.message?.content;
|
|
184
|
-
if (Array.isArray(errContent)) {
|
|
185
|
-
const errText = errContent
|
|
186
|
-
.filter((c) => c.type === 'text')
|
|
187
|
-
.map((c) => c.text)
|
|
188
|
-
.join('');
|
|
189
|
-
if (event.errorType === 'transient' && retryCount < 1) {
|
|
190
|
-
retryCount++;
|
|
191
|
-
log.info('transient 에러, 재시도 안내', { threadTs, retryCount });
|
|
192
|
-
// transient 에러 안내 (메시지가 있으면 update, 없으면 무시)
|
|
193
|
-
if (responseTs) {
|
|
194
|
-
await client.chat.update({ channel, ts: responseTs, text: ':warning: 일시적 오류가 발생했습니다...' }).catch(() => { });
|
|
147
|
+
switch (event.type) {
|
|
148
|
+
case 'start':
|
|
149
|
+
newSessionId = event.sessionId;
|
|
150
|
+
break;
|
|
151
|
+
case 'text':
|
|
152
|
+
if (event.text) {
|
|
153
|
+
if (event.text.replace) {
|
|
154
|
+
responseText = event.text.content;
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
responseText += event.text.content;
|
|
195
158
|
}
|
|
196
|
-
responseText += `\n:warning: 일시적 오류 발생. 재시도하려면 같은 메시지를 다시 보내주세요.`;
|
|
197
159
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
responseText
|
|
160
|
+
break;
|
|
161
|
+
case 'turn-end':
|
|
162
|
+
if (event.turn?.result) {
|
|
163
|
+
responseText = event.turn.result;
|
|
202
164
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
log.info('context overflow, 자동 압축 시도', { threadTs });
|
|
206
|
-
// context overflow 안내
|
|
207
|
-
if (responseTs) {
|
|
208
|
-
await client.chat.update({ channel, ts: responseTs, text: ':warning: 컨텍스트가 가득 차서 압축 중...' }).catch(() => { });
|
|
209
|
-
}
|
|
210
|
-
try {
|
|
211
|
-
const compactResult = runAgent({
|
|
212
|
-
prompt: '/compact',
|
|
213
|
-
sessionId: newSessionId || sessionId || undefined,
|
|
214
|
-
workDir: threadWorkDir,
|
|
215
|
-
});
|
|
216
|
-
for await (const ce of compactResult.stream) {
|
|
217
|
-
if (ce.type === 'result' && ce.session_id) {
|
|
218
|
-
newSessionId = ce.session_id;
|
|
219
|
-
setSession(threadTs, ce.session_id);
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
responseText += `\n:warning: 컨텍스트를 자동 압축했습니다. 다시 메시지를 보내주세요.`;
|
|
223
|
-
}
|
|
224
|
-
catch (compactErr) {
|
|
225
|
-
log.error('자동 압축 실패', { error: String(compactErr) });
|
|
226
|
-
responseText += `\n:x: 컨텍스트 압축 실패. \`/new\`로 세션을 초기화해주세요.`;
|
|
227
|
-
}
|
|
165
|
+
if (event.sessionId) {
|
|
166
|
+
newSessionId = event.sessionId;
|
|
228
167
|
}
|
|
229
|
-
|
|
230
|
-
|
|
168
|
+
if (event.turn?.usage) {
|
|
169
|
+
void recordUsage({
|
|
170
|
+
userId: userId || 'unknown',
|
|
171
|
+
threadTs,
|
|
172
|
+
timestamp: Date.now(),
|
|
173
|
+
model: event.turn.usage.model || 'unknown',
|
|
174
|
+
inputTokens: event.turn.usage.inputTokens,
|
|
175
|
+
outputTokens: event.turn.usage.outputTokens,
|
|
176
|
+
durationMs: Date.now() - startTime,
|
|
177
|
+
}, config.usage.dataDir);
|
|
231
178
|
}
|
|
232
|
-
|
|
179
|
+
break;
|
|
180
|
+
case 'tool-call-start':
|
|
181
|
+
toolUseCount++;
|
|
182
|
+
break;
|
|
183
|
+
case 'service':
|
|
184
|
+
await handleServiceEvent(event, {
|
|
185
|
+
threadTs, channel, responseTs, client,
|
|
186
|
+
sessionId, newSessionId, threadWorkDir,
|
|
187
|
+
retryCount, didAutoCompact,
|
|
188
|
+
onRetry: () => { retryCount++; },
|
|
189
|
+
onCompacted: () => { didAutoCompact = true; },
|
|
190
|
+
onSessionId: (id) => { newSessionId = id; },
|
|
191
|
+
appendText: (t) => { responseText += t; },
|
|
192
|
+
});
|
|
193
|
+
break;
|
|
194
|
+
default:
|
|
195
|
+
break;
|
|
233
196
|
}
|
|
234
|
-
// 스트리밍 업데이트 (일정
|
|
197
|
+
// 스트리밍 업데이트 (일정 간격)
|
|
235
198
|
const now = Date.now();
|
|
236
199
|
if (responseText && now - lastUpdateAt >= STREAM_UPDATE_INTERVAL_MS) {
|
|
237
200
|
lastUpdateAt = now;
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
201
|
+
// splitMessage로 코드 블록 경계를 존중하며 첫 번째 청크를 미리보기로 표시
|
|
202
|
+
const previewChunk = splitMessage(responseText)[0];
|
|
203
|
+
const preview = previewChunk + '\n_... (처리 중)_';
|
|
241
204
|
if (!responseTs) {
|
|
242
|
-
|
|
243
|
-
const msg = await client.chat.postMessage({
|
|
244
|
-
channel,
|
|
245
|
-
thread_ts: threadTs,
|
|
246
|
-
text: preview,
|
|
247
|
-
}).catch(() => null);
|
|
205
|
+
const msg = await withRetry(() => client.chat.postMessage({ channel, thread_ts: threadTs, text: preview }), 'chat.postMessage(stream)').catch(() => null);
|
|
248
206
|
if (msg?.ts)
|
|
249
207
|
responseTs = msg.ts;
|
|
250
208
|
}
|
|
251
209
|
else {
|
|
252
|
-
await client.chat.update({
|
|
253
|
-
channel,
|
|
254
|
-
ts: responseTs,
|
|
255
|
-
text: preview,
|
|
256
|
-
}).catch(() => { });
|
|
210
|
+
await bestEffort(() => client.chat.update({ channel, ts: responseTs, text: preview }), 'chat.update(stream)');
|
|
257
211
|
}
|
|
258
212
|
}
|
|
259
213
|
}
|
|
@@ -264,9 +218,14 @@ async function handleIncoming(text, channel, threadTs, messageTs, client, userId
|
|
|
264
218
|
kill();
|
|
265
219
|
}
|
|
266
220
|
}
|
|
267
|
-
// 4. 세션 저장
|
|
221
|
+
// 4. 세션 저장 (메타데이터 포함)
|
|
268
222
|
if (newSessionId) {
|
|
269
|
-
setSession(threadTs, newSessionId
|
|
223
|
+
setSession(threadTs, newSessionId, {
|
|
224
|
+
userId,
|
|
225
|
+
channel,
|
|
226
|
+
model: config.agent.model || undefined,
|
|
227
|
+
lastPrompt: text,
|
|
228
|
+
});
|
|
270
229
|
log.info('세션 저장', { threadTs, sessionId: newSessionId });
|
|
271
230
|
}
|
|
272
231
|
// 5. 응답 전송
|
|
@@ -279,54 +238,28 @@ async function handleIncoming(text, channel, threadTs, messageTs, client, userId
|
|
|
279
238
|
}
|
|
280
239
|
const { text: formatted, snippets } = formatResponse(responseText);
|
|
281
240
|
const chunks = splitMessage(formatted);
|
|
241
|
+
// 여러 청크일 때 n/total 번호 표시
|
|
242
|
+
const total = chunks.length;
|
|
243
|
+
const withIndex = (text, idx) => total > 1 ? `_(${idx + 1}/${total})_\n${text}` : text;
|
|
244
|
+
// 스트리밍 미리보기 메시지 삭제 (있으면)
|
|
282
245
|
if (responseTs) {
|
|
283
|
-
|
|
284
|
-
await client.chat.update({
|
|
285
|
-
channel,
|
|
286
|
-
ts: responseTs,
|
|
287
|
-
text: chunks[0],
|
|
288
|
-
});
|
|
289
|
-
}
|
|
290
|
-
else {
|
|
291
|
-
// 스트리밍 업데이트가 없었으면 새 메시지로 전송
|
|
292
|
-
const msg = await client.chat.postMessage({
|
|
293
|
-
channel,
|
|
294
|
-
thread_ts: threadTs,
|
|
295
|
-
text: chunks[0],
|
|
296
|
-
});
|
|
297
|
-
responseTs = msg.ts;
|
|
246
|
+
await bestEffort(() => client.chat.delete({ channel, ts: responseTs }), 'chat.delete(preview)');
|
|
298
247
|
}
|
|
299
|
-
//
|
|
300
|
-
for (let i =
|
|
301
|
-
await client.chat.postMessage({
|
|
302
|
-
channel,
|
|
303
|
-
thread_ts: threadTs,
|
|
304
|
-
text: chunks[i],
|
|
305
|
-
});
|
|
248
|
+
// 모든 청크를 새 메시지로 순차 전송 (1/N, 2/N, ...)
|
|
249
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
250
|
+
await withRetry(() => client.chat.postMessage({ channel, thread_ts: threadTs, text: withIndex(chunks[i], i) }), `chat.postMessage(chunk ${i})`);
|
|
306
251
|
}
|
|
307
|
-
// 코드 스니펫 파일 업로드
|
|
308
252
|
for (const snippet of snippets) {
|
|
309
|
-
await client.filesUploadV2({
|
|
253
|
+
await withRetry(() => client.filesUploadV2({
|
|
310
254
|
channel_id: channel,
|
|
311
255
|
thread_ts: threadTs,
|
|
312
256
|
filename: snippet.filename,
|
|
313
257
|
content: snippet.content,
|
|
314
258
|
title: snippet.filename,
|
|
315
|
-
}).catch((e) => {
|
|
259
|
+
}), `filesUploadV2(${snippet.filename})`).catch((e) => {
|
|
316
260
|
log.error('스니펫 업로드 실패', { filename: snippet.filename, error: String(e) });
|
|
317
261
|
});
|
|
318
262
|
}
|
|
319
|
-
// 6. 리액션 변경
|
|
320
|
-
await client.reactions.remove({
|
|
321
|
-
channel,
|
|
322
|
-
name: 'hourglass_flowing_sand',
|
|
323
|
-
timestamp: messageTs,
|
|
324
|
-
}).catch(() => { });
|
|
325
|
-
await client.reactions.add({
|
|
326
|
-
channel,
|
|
327
|
-
name: 'white_check_mark',
|
|
328
|
-
timestamp: messageTs,
|
|
329
|
-
}).catch(() => { });
|
|
330
263
|
if (toolUseCount > 0) {
|
|
331
264
|
log.info('도구 사용', { threadTs, toolUseCount });
|
|
332
265
|
}
|
|
@@ -335,27 +268,16 @@ async function handleIncoming(text, channel, threadTs, messageTs, client, userId
|
|
|
335
268
|
log.error('처리 오류', { error: String(error), threadTs });
|
|
336
269
|
const errMsg = `:x: 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`;
|
|
337
270
|
if (responseTs) {
|
|
338
|
-
await client.chat.update({ channel, ts: responseTs, text: errMsg }).
|
|
271
|
+
await bestEffort(() => client.chat.update({ channel, ts: responseTs, text: errMsg }), 'chat.update(error)');
|
|
339
272
|
}
|
|
340
273
|
else {
|
|
341
|
-
await client.chat.postMessage({ channel, thread_ts: threadTs, text: errMsg }).
|
|
274
|
+
await bestEffort(() => client.chat.postMessage({ channel, thread_ts: threadTs, text: errMsg }), 'chat.postMessage(error)');
|
|
342
275
|
}
|
|
343
|
-
await client.reactions.remove({
|
|
344
|
-
channel,
|
|
345
|
-
name: 'hourglass_flowing_sand',
|
|
346
|
-
timestamp: messageTs,
|
|
347
|
-
}).catch(() => { });
|
|
348
|
-
await client.reactions.add({
|
|
349
|
-
channel,
|
|
350
|
-
name: 'x',
|
|
351
|
-
timestamp: messageTs,
|
|
352
|
-
}).catch(() => { });
|
|
353
276
|
}
|
|
354
277
|
finally {
|
|
355
|
-
|
|
278
|
+
deleteActiveKill(threadTs);
|
|
356
279
|
clearTimeout(timeoutHandle);
|
|
357
280
|
await setTypingStatus(client, channel, threadTs, '');
|
|
358
|
-
// 승인 서버 정리
|
|
359
281
|
const approvalKey = getApprovalKey(channel, threadTs);
|
|
360
282
|
activeApprovalServers.delete(approvalKey);
|
|
361
283
|
if (approvalServer) {
|
|
@@ -364,36 +286,59 @@ async function handleIncoming(text, channel, threadTs, messageTs, client, userId
|
|
|
364
286
|
if (cleanupHook) {
|
|
365
287
|
await cleanupHook().catch(() => { });
|
|
366
288
|
}
|
|
367
|
-
|
|
289
|
+
decrementActiveCount();
|
|
368
290
|
}
|
|
369
291
|
});
|
|
370
292
|
}
|
|
371
|
-
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
293
|
+
async function handleServiceEvent(event, ctx) {
|
|
294
|
+
const svc = event.service;
|
|
295
|
+
if (!svc)
|
|
296
|
+
return;
|
|
297
|
+
if (svc.errorType === 'transient' && ctx.retryCount < 1) {
|
|
298
|
+
ctx.onRetry();
|
|
299
|
+
log.info('transient 에러, 재시도 안내', { threadTs: ctx.threadTs });
|
|
300
|
+
if (ctx.responseTs) {
|
|
301
|
+
await ctx.client.chat.update({ channel: ctx.channel, ts: ctx.responseTs, text: ':warning: 일시적 오류가 발생했습니다...' }).catch(() => { });
|
|
302
|
+
}
|
|
303
|
+
ctx.appendText('\n:warning: 일시적 오류 발생. 재시도하려면 같은 메시지를 다시 보내주세요.');
|
|
379
304
|
}
|
|
380
|
-
if (
|
|
381
|
-
|
|
305
|
+
else if (svc.errorType === 'session_corrupt') {
|
|
306
|
+
log.info('session corrupt, 세션 초기화', { threadTs: ctx.threadTs });
|
|
307
|
+
clearSession(ctx.threadTs);
|
|
308
|
+
ctx.appendText('\n:warning: 세션 오류가 발생하여 세션을 초기화했습니다. 다시 메시지를 보내주세요.');
|
|
382
309
|
}
|
|
383
|
-
if (
|
|
384
|
-
|
|
310
|
+
else if (svc.errorType === 'context_overflow' && !ctx.didAutoCompact) {
|
|
311
|
+
ctx.onCompacted();
|
|
312
|
+
log.info('context overflow, 자동 압축 시도', { threadTs: ctx.threadTs });
|
|
313
|
+
if (ctx.responseTs) {
|
|
314
|
+
await ctx.client.chat.update({ channel: ctx.channel, ts: ctx.responseTs, text: ':warning: 컨텍스트가 가득 차서 압축 중...' }).catch(() => { });
|
|
315
|
+
}
|
|
316
|
+
try {
|
|
317
|
+
const compactResult = runAgent({
|
|
318
|
+
prompt: '/compact',
|
|
319
|
+
sessionId: ctx.newSessionId || ctx.sessionId || undefined,
|
|
320
|
+
workDir: ctx.threadWorkDir,
|
|
321
|
+
});
|
|
322
|
+
for await (const ce of compactResult.stream) {
|
|
323
|
+
if (ce.type === 'result' && ce.session_id) {
|
|
324
|
+
ctx.onSessionId(ce.session_id);
|
|
325
|
+
setSession(ctx.threadTs, ce.session_id);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
ctx.appendText('\n:warning: 컨텍스트를 자동 압축했습니다. 다시 메시지를 보내주세요.');
|
|
329
|
+
}
|
|
330
|
+
catch (compactErr) {
|
|
331
|
+
log.error('자동 압축 실패', { error: String(compactErr) });
|
|
332
|
+
ctx.appendText('\n:x: 컨텍스트 압축 실패. `$new`로 세션을 초기화해주세요.');
|
|
333
|
+
}
|
|
385
334
|
}
|
|
386
|
-
|
|
387
|
-
|
|
335
|
+
else {
|
|
336
|
+
ctx.appendText(`\n:x: ${svc.message}`);
|
|
388
337
|
}
|
|
389
|
-
// 기본: 주요 필드 요약
|
|
390
|
-
const keys = Object.keys(toolInput).slice(0, 3);
|
|
391
|
-
if (keys.length === 0)
|
|
392
|
-
return '(입력 없음)';
|
|
393
|
-
return keys.map((k) => `${k}: ${String(toolInput[k]).slice(0, 100)}`).join(', ');
|
|
394
338
|
}
|
|
395
339
|
async function sendApprovalButtons(client, channel, threadTs, request) {
|
|
396
|
-
const summary =
|
|
340
|
+
const summary = formatToolInline({ toolName: request.toolName, toolInput: request.toolInput });
|
|
341
|
+
const buttonValue = `${request.requestId}:${request.toolName}`;
|
|
397
342
|
try {
|
|
398
343
|
await client.chat.postMessage({
|
|
399
344
|
channel,
|
|
@@ -412,17 +357,23 @@ async function sendApprovalButtons(client, channel, threadTs, request) {
|
|
|
412
357
|
elements: [
|
|
413
358
|
{
|
|
414
359
|
type: 'button',
|
|
415
|
-
text: { type: 'plain_text', text: '
|
|
360
|
+
text: { type: 'plain_text', text: '이번만 승인' },
|
|
416
361
|
style: 'primary',
|
|
417
362
|
action_id: 'approve_tool',
|
|
418
|
-
value:
|
|
363
|
+
value: buttonValue,
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
type: 'button',
|
|
367
|
+
text: { type: 'plain_text', text: '항상 허용' },
|
|
368
|
+
action_id: 'approve_tool_always',
|
|
369
|
+
value: buttonValue,
|
|
419
370
|
},
|
|
420
371
|
{
|
|
421
372
|
type: 'button',
|
|
422
|
-
text: { type: 'plain_text', text: '
|
|
373
|
+
text: { type: 'plain_text', text: '거부' },
|
|
423
374
|
style: 'danger',
|
|
424
375
|
action_id: 'deny_tool',
|
|
425
|
-
value:
|
|
376
|
+
value: buttonValue,
|
|
426
377
|
},
|
|
427
378
|
],
|
|
428
379
|
},
|
|
@@ -433,14 +384,115 @@ async function sendApprovalButtons(client, channel, threadTs, request) {
|
|
|
433
384
|
log.error('승인 버튼 전송 실패', { error: String(e) });
|
|
434
385
|
}
|
|
435
386
|
}
|
|
387
|
+
function parseButtonValue(raw) {
|
|
388
|
+
const idx = raw.indexOf(':');
|
|
389
|
+
if (idx === -1)
|
|
390
|
+
return [raw, ''];
|
|
391
|
+
return [raw.slice(0, idx), raw.slice(idx + 1)];
|
|
392
|
+
}
|
|
436
393
|
export function registerHandlers(app) {
|
|
437
|
-
//
|
|
394
|
+
// Slack Assistant 핸들러 (DM Messages 탭)
|
|
395
|
+
const assistant = new Assistant({
|
|
396
|
+
threadStarted: async ({ say, setSuggestedPrompts, setTitle }) => {
|
|
397
|
+
await setTitle('Choavis Agent');
|
|
398
|
+
await say('무엇을 도와드릴까요? 코드 작성, 리뷰, 버그 수정, Git 작업 등 터미널에서 할 수 있는 모든 작업을 처리할 수 있습니다.');
|
|
399
|
+
await setSuggestedPrompts({
|
|
400
|
+
prompts: [
|
|
401
|
+
{ title: '프로젝트 구조 확인', message: '현재 프로젝트의 구조를 분석해줘' },
|
|
402
|
+
{ title: '코드 리뷰', message: '최근 변경사항을 코드 리뷰해줘' },
|
|
403
|
+
{ title: 'Git 상태 확인', message: 'git status 확인해줘' },
|
|
404
|
+
],
|
|
405
|
+
});
|
|
406
|
+
},
|
|
407
|
+
userMessage: async ({ message, client, setStatus }) => {
|
|
408
|
+
const msg = message;
|
|
409
|
+
if ((msg.subtype && msg.subtype !== 'file_share') || msg.bot_id)
|
|
410
|
+
return;
|
|
411
|
+
const text = msg.text;
|
|
412
|
+
log.info('userMessage 수신', { text: text?.slice(0, 50), subtype: msg.subtype, filesCount: msg.files?.length ?? 0, msgKeys: Object.keys(message) });
|
|
413
|
+
if (!text && !msg.files?.length)
|
|
414
|
+
return;
|
|
415
|
+
const threadTs = msg.thread_ts || msg.ts;
|
|
416
|
+
await setStatus('처리 중...').catch(() => { });
|
|
417
|
+
let slackContext = '';
|
|
418
|
+
let threadFiles = [];
|
|
419
|
+
if (msg.thread_ts && !getSession(threadTs)) {
|
|
420
|
+
const ctx = await fetchThreadContext(client, msg.channel, msg.thread_ts, msg.ts);
|
|
421
|
+
slackContext = ctx.text;
|
|
422
|
+
threadFiles = ctx.files;
|
|
423
|
+
}
|
|
424
|
+
const allFiles = [...threadFiles, ...(msg.files || [])];
|
|
425
|
+
await handleIncoming(text || '(첨부 파일을 확인해주세요)', msg.channel, threadTs, msg.ts, client, msg.user, slackContext, allFiles.length > 0 ? allFiles : undefined);
|
|
426
|
+
},
|
|
427
|
+
});
|
|
428
|
+
app.assistant(assistant);
|
|
429
|
+
// App Home 열기 핸들러
|
|
430
|
+
app.event('app_home_opened', async ({ event, client }) => {
|
|
431
|
+
if (event.tab !== 'home')
|
|
432
|
+
return;
|
|
433
|
+
try {
|
|
434
|
+
await client.views.publish({
|
|
435
|
+
user_id: event.user,
|
|
436
|
+
view: {
|
|
437
|
+
type: 'home',
|
|
438
|
+
blocks: [
|
|
439
|
+
{
|
|
440
|
+
type: 'header',
|
|
441
|
+
text: { type: 'plain_text', text: 'Choavis Agent' },
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
type: 'section',
|
|
445
|
+
text: {
|
|
446
|
+
type: 'mrkdwn',
|
|
447
|
+
text: 'Slack을 통해 Claude Code를 실행하는 AI 코딩 에이전트입니다.',
|
|
448
|
+
},
|
|
449
|
+
},
|
|
450
|
+
{ type: 'divider' },
|
|
451
|
+
{
|
|
452
|
+
type: 'section',
|
|
453
|
+
text: {
|
|
454
|
+
type: 'mrkdwn',
|
|
455
|
+
text: '*사용 방법*\n\n'
|
|
456
|
+
+ ':speech_balloon: *DM* — 이 앱의 *메시지 탭*에서 새 대화를 시작하세요\n'
|
|
457
|
+
+ ':mega: *채널 멘션* — 채널에서 `@Choavis`를 멘션하세요\n',
|
|
458
|
+
},
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
type: 'section',
|
|
462
|
+
text: {
|
|
463
|
+
type: 'mrkdwn',
|
|
464
|
+
text: '*명령어*\n\n'
|
|
465
|
+
+ '`$stop` / `$중지` — 실행 중인 작업 중단\n'
|
|
466
|
+
+ '`$new` — 세션 초기화 (대화 리셋)\n'
|
|
467
|
+
+ '`$compact` — 컨텍스트 압축\n'
|
|
468
|
+
+ '`$model <이름>` — 모델 변경 (sonnet/opus/haiku)\n'
|
|
469
|
+
+ '`$permission <모드>` — 권한 모드 변경 (bypass/plan/accept)\n'
|
|
470
|
+
+ '`$status` — 현재 설정 및 상태 확인\n'
|
|
471
|
+
+ '`$sessions` / `$세션` — 활성 세션 목록\n'
|
|
472
|
+
+ '`$resume <ID>` / `$이어서` — 기존 세션에 연결\n'
|
|
473
|
+
+ '`$inject <ID> <명령>` — 특정 세션에 명령 주입\n'
|
|
474
|
+
+ '`$usage` / `사용량` — 사용량 대시보드\n'
|
|
475
|
+
+ '`기억해: 내용` — 기억 저장\n'
|
|
476
|
+
+ '`기억 목록` — 저장된 기억 조회\n'
|
|
477
|
+
+ '`프로젝트: 이름` — 작업 디렉토리 전환\n'
|
|
478
|
+
+ '`$help` — 전체 도움말',
|
|
479
|
+
},
|
|
480
|
+
},
|
|
481
|
+
],
|
|
482
|
+
},
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
catch (e) {
|
|
486
|
+
log.error('App Home 업데이트 실패', { error: String(e), user: event.user });
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
// 승인 action 핸들러 — 이번만 승인
|
|
438
490
|
app.action('approve_tool', async ({ action, body, client, ack }) => {
|
|
439
491
|
await ack();
|
|
440
|
-
const
|
|
492
|
+
const raw = 'value' in action ? action.value : '';
|
|
493
|
+
const [requestId, toolName] = parseButtonValue(raw);
|
|
441
494
|
const channel = body.channel?.id;
|
|
442
495
|
const messageTs = body.message?.ts;
|
|
443
|
-
// 모든 활성 승인 서버에서 요청 찾기
|
|
444
496
|
let resolved = false;
|
|
445
497
|
for (const server of activeApprovalServers.values()) {
|
|
446
498
|
if (server.resolve(requestId, 'allow')) {
|
|
@@ -449,25 +501,68 @@ export function registerHandlers(app) {
|
|
|
449
501
|
}
|
|
450
502
|
}
|
|
451
503
|
if (channel && messageTs) {
|
|
504
|
+
const label = toolName ? ` — \`${toolName}\`` : '';
|
|
452
505
|
await client.chat.update({
|
|
453
506
|
channel,
|
|
454
507
|
ts: messageTs,
|
|
455
|
-
text: ':white_check_mark: 승인됨',
|
|
508
|
+
text: ':white_check_mark: 승인됨 (이번만)',
|
|
456
509
|
blocks: [
|
|
457
510
|
{
|
|
458
511
|
type: 'section',
|
|
459
512
|
text: {
|
|
460
513
|
type: 'mrkdwn',
|
|
461
|
-
text: `:white_check_mark: *승인됨* ${resolved ? '' : '(이미 처리됨)'}`,
|
|
514
|
+
text: `:white_check_mark: *승인됨* (이번만)${label} ${resolved ? '' : '(이미 처리됨)'}`,
|
|
462
515
|
},
|
|
463
516
|
},
|
|
464
517
|
],
|
|
465
518
|
}).catch(() => { });
|
|
466
519
|
}
|
|
467
520
|
});
|
|
521
|
+
// 승인 action 핸들러 — 항상 허용
|
|
522
|
+
app.action('approve_tool_always', async ({ action, body, client, ack }) => {
|
|
523
|
+
await ack();
|
|
524
|
+
const raw = 'value' in action ? action.value : '';
|
|
525
|
+
const [requestId, toolName] = parseButtonValue(raw);
|
|
526
|
+
const channel = body.channel?.id;
|
|
527
|
+
const threadTs = body.message?.thread_ts;
|
|
528
|
+
const messageTs = body.message?.ts;
|
|
529
|
+
let resolved = false;
|
|
530
|
+
for (const server of activeApprovalServers.values()) {
|
|
531
|
+
if (server.resolve(requestId, 'allow', true)) {
|
|
532
|
+
resolved = true;
|
|
533
|
+
break;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
// settings.local.json에 도구 영구 등록
|
|
537
|
+
if (toolName) {
|
|
538
|
+
const workDir = (threadTs ? getWorkDir(threadTs) : null) || config.agent.workDir;
|
|
539
|
+
await addToolToSettings(workDir, toolName).catch((e) => {
|
|
540
|
+
log.error('settings.local.json 업데이트 실패', { error: String(e), toolName });
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
if (channel && messageTs) {
|
|
544
|
+
const label = toolName ? ` — \`${toolName}\`` : '';
|
|
545
|
+
await client.chat.update({
|
|
546
|
+
channel,
|
|
547
|
+
ts: messageTs,
|
|
548
|
+
text: ':white_check_mark: 항상 허용됨',
|
|
549
|
+
blocks: [
|
|
550
|
+
{
|
|
551
|
+
type: 'section',
|
|
552
|
+
text: {
|
|
553
|
+
type: 'mrkdwn',
|
|
554
|
+
text: `:white_check_mark: *항상 허용됨*${label} ${resolved ? '' : '(이미 처리됨)'}`,
|
|
555
|
+
},
|
|
556
|
+
},
|
|
557
|
+
],
|
|
558
|
+
}).catch(() => { });
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
// 승인 action 핸들러 — 거부
|
|
468
562
|
app.action('deny_tool', async ({ action, body, client, ack }) => {
|
|
469
563
|
await ack();
|
|
470
|
-
const
|
|
564
|
+
const raw = 'value' in action ? action.value : '';
|
|
565
|
+
const [requestId, toolName] = parseButtonValue(raw);
|
|
471
566
|
const channel = body.channel?.id;
|
|
472
567
|
const messageTs = body.message?.ts;
|
|
473
568
|
let resolved = false;
|
|
@@ -478,6 +573,7 @@ export function registerHandlers(app) {
|
|
|
478
573
|
}
|
|
479
574
|
}
|
|
480
575
|
if (channel && messageTs) {
|
|
576
|
+
const label = toolName ? ` — \`${toolName}\`` : '';
|
|
481
577
|
await client.chat.update({
|
|
482
578
|
channel,
|
|
483
579
|
ts: messageTs,
|
|
@@ -487,49 +583,66 @@ export function registerHandlers(app) {
|
|
|
487
583
|
type: 'section',
|
|
488
584
|
text: {
|
|
489
585
|
type: 'mrkdwn',
|
|
490
|
-
text: `:x:
|
|
586
|
+
text: `:x: *거부됨*${label} ${resolved ? '' : '(이미 처리됨)'}`,
|
|
491
587
|
},
|
|
492
588
|
},
|
|
493
589
|
],
|
|
494
590
|
}).catch(() => { });
|
|
495
591
|
}
|
|
496
592
|
});
|
|
497
|
-
// DM 메시지 핸들러
|
|
593
|
+
// DM 메시지 핸들러
|
|
498
594
|
app.message(async ({ message, client }) => {
|
|
499
595
|
const msg = message;
|
|
500
|
-
if (msg.subtype || msg.bot_id)
|
|
596
|
+
if ((msg.subtype && msg.subtype !== 'file_share') || msg.bot_id)
|
|
501
597
|
return;
|
|
502
598
|
const text = msg.text;
|
|
503
|
-
if (!text)
|
|
599
|
+
if (!text && !msg.files?.length)
|
|
504
600
|
return;
|
|
505
601
|
const isDM = msg.channel_type === 'im';
|
|
506
|
-
|
|
507
|
-
|
|
602
|
+
// @멘션으로 시작된 스레드의 후속 메시지도 처리
|
|
603
|
+
const isFollowUp = !isDM && msg.thread_ts && (mentionStartedThreads.has(msg.thread_ts) || isBotStarted(msg.thread_ts));
|
|
604
|
+
if (!isDM && !isFollowUp)
|
|
605
|
+
return;
|
|
508
606
|
const threadTs = msg.thread_ts || msg.ts;
|
|
509
|
-
// DM 스레드에서 새 세션이면 이전 대화 컨텍스트 주입
|
|
510
607
|
let slackContext = '';
|
|
608
|
+
let threadFiles = [];
|
|
511
609
|
if (msg.thread_ts && !getSession(threadTs)) {
|
|
512
|
-
|
|
610
|
+
const ctx = await fetchThreadContext(client, msg.channel, msg.thread_ts, msg.ts);
|
|
611
|
+
slackContext = ctx.text;
|
|
612
|
+
threadFiles = ctx.files;
|
|
513
613
|
}
|
|
514
|
-
|
|
614
|
+
const cleanText = isFollowUp
|
|
615
|
+
? (text?.replace(/<@[A-Z0-9]+>/g, '').trim() || text || '')
|
|
616
|
+
: (text || '');
|
|
617
|
+
const allFiles = [...threadFiles, ...(msg.files || [])];
|
|
618
|
+
await handleIncoming(cleanText || '(첨부 파일을 확인해주세요)', msg.channel, threadTs, msg.ts, client, msg.user, slackContext, allFiles.length > 0 ? allFiles : undefined);
|
|
515
619
|
});
|
|
516
620
|
// 채널 @멘션 핸들러
|
|
517
621
|
app.event('app_mention', async ({ event, client }) => {
|
|
518
622
|
const text = event.text?.replace(/<@[A-Z0-9]+>/g, '').trim();
|
|
519
|
-
|
|
623
|
+
const files = event.files;
|
|
624
|
+
if (!text && !files?.length)
|
|
520
625
|
return;
|
|
521
626
|
const threadTs = event.thread_ts || event.ts;
|
|
627
|
+
// @멘션으로 스레드를 시작한 경우만 추적
|
|
628
|
+
if (!event.thread_ts) {
|
|
629
|
+
trackMentionThread(event.ts);
|
|
630
|
+
markBotStarted(event.ts);
|
|
631
|
+
}
|
|
522
632
|
// Slack 대화 컨텍스트 수집
|
|
523
633
|
let slackContext = '';
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
634
|
+
let threadFiles = [];
|
|
635
|
+
if (event.thread_ts && !getSession(threadTs)) {
|
|
636
|
+
const ctx = await fetchThreadContext(client, event.channel, event.thread_ts, event.ts);
|
|
637
|
+
slackContext = ctx.text;
|
|
638
|
+
threadFiles = ctx.files;
|
|
527
639
|
}
|
|
528
|
-
else {
|
|
640
|
+
else if (!event.thread_ts) {
|
|
529
641
|
// 최상위 멘션: 채널 최근 대화 주입
|
|
530
642
|
slackContext = await fetchChannelHistory(client, event.channel);
|
|
531
643
|
}
|
|
532
|
-
|
|
644
|
+
const allFiles = [...threadFiles, ...(files || [])];
|
|
645
|
+
await handleIncoming(text || '(첨부 파일을 확인해주세요)', event.channel, threadTs, event.ts, client, event.user, slackContext, allFiles.length > 0 ? allFiles : undefined);
|
|
533
646
|
});
|
|
534
647
|
}
|
|
535
648
|
//# sourceMappingURL=handlers.js.map
|