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.
Files changed (131) hide show
  1. package/.env.example +3 -2
  2. package/README.md +120 -205
  3. package/dist/agent/adapters/claude.d.ts +13 -0
  4. package/dist/agent/adapters/claude.d.ts.map +1 -0
  5. package/dist/agent/adapters/claude.js +199 -0
  6. package/dist/agent/adapters/claude.js.map +1 -0
  7. package/dist/agent/protocol.d.ts +56 -0
  8. package/dist/agent/protocol.d.ts.map +1 -0
  9. package/dist/agent/protocol.js +9 -0
  10. package/dist/agent/protocol.js.map +1 -0
  11. package/dist/agent/queue.d.ts.map +1 -1
  12. package/dist/agent/queue.js +4 -2
  13. package/dist/agent/queue.js.map +1 -1
  14. package/dist/agent/runner.d.ts +8 -0
  15. package/dist/agent/runner.d.ts.map +1 -1
  16. package/dist/agent/runner.js +13 -39
  17. package/dist/agent/runner.js.map +1 -1
  18. package/dist/agent/session.d.ts +27 -1
  19. package/dist/agent/session.d.ts.map +1 -1
  20. package/dist/agent/session.js +79 -3
  21. package/dist/agent/session.js.map +1 -1
  22. package/dist/agent/usage.d.ts +8 -5
  23. package/dist/agent/usage.d.ts.map +1 -1
  24. package/dist/agent/usage.js +21 -0
  25. package/dist/agent/usage.js.map +1 -1
  26. package/dist/approval/server.d.ts +11 -1
  27. package/dist/approval/server.d.ts.map +1 -1
  28. package/dist/approval/server.js +46 -4
  29. package/dist/approval/server.js.map +1 -1
  30. package/dist/approval/setup.d.ts +5 -0
  31. package/dist/approval/setup.d.ts.map +1 -1
  32. package/dist/approval/setup.js +31 -0
  33. package/dist/approval/setup.js.map +1 -1
  34. package/dist/cli/config.d.ts.map +1 -1
  35. package/dist/cli/config.js +18 -8
  36. package/dist/cli/config.js.map +1 -1
  37. package/dist/cli/onboard.d.ts.map +1 -1
  38. package/dist/cli/onboard.js +299 -51
  39. package/dist/cli/onboard.js.map +1 -1
  40. package/dist/cli.js +10 -5
  41. package/dist/cli.js.map +1 -1
  42. package/dist/config.d.ts +5 -0
  43. package/dist/config.d.ts.map +1 -1
  44. package/dist/config.js +9 -1
  45. package/dist/config.js.map +1 -1
  46. package/dist/index.js +2 -2
  47. package/dist/index.js.map +1 -1
  48. package/dist/slack/commands/compact.js +2 -2
  49. package/dist/slack/commands/compact.js.map +1 -1
  50. package/dist/slack/commands/help.d.ts.map +1 -1
  51. package/dist/slack/commands/help.js +16 -11
  52. package/dist/slack/commands/help.js.map +1 -1
  53. package/dist/slack/commands/index.d.ts.map +1 -1
  54. package/dist/slack/commands/index.js +26 -0
  55. package/dist/slack/commands/index.js.map +1 -1
  56. package/dist/slack/commands/inject.d.ts +3 -0
  57. package/dist/slack/commands/inject.d.ts.map +1 -0
  58. package/dist/slack/commands/inject.js +44 -0
  59. package/dist/slack/commands/inject.js.map +1 -0
  60. package/dist/slack/commands/memory.js +7 -7
  61. package/dist/slack/commands/memory.js.map +1 -1
  62. package/dist/slack/commands/model.d.ts +3 -0
  63. package/dist/slack/commands/model.d.ts.map +1 -0
  64. package/dist/slack/commands/model.js +43 -0
  65. package/dist/slack/commands/model.js.map +1 -0
  66. package/dist/slack/commands/new.js +1 -1
  67. package/dist/slack/commands/new.js.map +1 -1
  68. package/dist/slack/commands/permission.d.ts +3 -0
  69. package/dist/slack/commands/permission.d.ts.map +1 -0
  70. package/dist/slack/commands/permission.js +51 -0
  71. package/dist/slack/commands/permission.js.map +1 -0
  72. package/dist/slack/commands/project.js +2 -2
  73. package/dist/slack/commands/project.js.map +1 -1
  74. package/dist/slack/commands/resume.d.ts +3 -0
  75. package/dist/slack/commands/resume.d.ts.map +1 -0
  76. package/dist/slack/commands/resume.js +93 -0
  77. package/dist/slack/commands/resume.js.map +1 -0
  78. package/dist/slack/commands/session-info.d.ts +3 -0
  79. package/dist/slack/commands/session-info.d.ts.map +1 -0
  80. package/dist/slack/commands/session-info.js +33 -0
  81. package/dist/slack/commands/session-info.js.map +1 -0
  82. package/dist/slack/commands/sessions.d.ts +3 -0
  83. package/dist/slack/commands/sessions.d.ts.map +1 -0
  84. package/dist/slack/commands/sessions.js +54 -0
  85. package/dist/slack/commands/sessions.js.map +1 -0
  86. package/dist/slack/commands/status.d.ts.map +1 -1
  87. package/dist/slack/commands/status.js +7 -2
  88. package/dist/slack/commands/status.js.map +1 -1
  89. package/dist/slack/commands/usage.d.ts +5 -0
  90. package/dist/slack/commands/usage.d.ts.map +1 -1
  91. package/dist/slack/commands/usage.js +93 -12
  92. package/dist/slack/commands/usage.js.map +1 -1
  93. package/dist/slack/context.d.ts +11 -8
  94. package/dist/slack/context.d.ts.map +1 -1
  95. package/dist/slack/context.js +36 -14
  96. package/dist/slack/context.js.map +1 -1
  97. package/dist/slack/file-downloader.d.ts +18 -0
  98. package/dist/slack/file-downloader.d.ts.map +1 -0
  99. package/dist/slack/file-downloader.js +196 -0
  100. package/dist/slack/file-downloader.js.map +1 -0
  101. package/dist/slack/formatter.d.ts +0 -4
  102. package/dist/slack/formatter.d.ts.map +1 -1
  103. package/dist/slack/formatter.js +0 -8
  104. package/dist/slack/formatter.js.map +1 -1
  105. package/dist/slack/handlers.d.ts +3 -4
  106. package/dist/slack/handlers.d.ts.map +1 -1
  107. package/dist/slack/handlers.js +372 -259
  108. package/dist/slack/handlers.js.map +1 -1
  109. package/dist/slack/link-resolver.d.ts +24 -0
  110. package/dist/slack/link-resolver.d.ts.map +1 -0
  111. package/dist/slack/link-resolver.js +52 -0
  112. package/dist/slack/link-resolver.js.map +1 -0
  113. package/dist/slack/middleware.d.ts +17 -0
  114. package/dist/slack/middleware.d.ts.map +1 -0
  115. package/dist/slack/middleware.js +73 -0
  116. package/dist/slack/middleware.js.map +1 -0
  117. package/dist/slack/slack-retry.d.ts +18 -0
  118. package/dist/slack/slack-retry.d.ts.map +1 -0
  119. package/dist/slack/slack-retry.js +71 -0
  120. package/dist/slack/slack-retry.js.map +1 -0
  121. package/dist/slack/tool-formatter.d.ts +18 -0
  122. package/dist/slack/tool-formatter.d.ts.map +1 -0
  123. package/dist/slack/tool-formatter.js +154 -0
  124. package/dist/slack/tool-formatter.js.map +1 -0
  125. package/dist/slack/types.d.ts +22 -0
  126. package/dist/slack/types.d.ts.map +1 -0
  127. package/dist/slack/types.js +2 -0
  128. package/dist/slack/types.js.map +1 -0
  129. package/package.json +10 -10
  130. package/scripts/pretool-hook.mjs +0 -0
  131. package/slack-app-manifest.json +12 -1
@@ -1,7 +1,9 @@
1
- import { runAgent } from '../agent/runner.js';
2
- import { getSession, setSession, clearSession, getWorkDir } from '../agent/session.js';
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
- // 이벤트 중복 처리 방지 (message + app_mention 동시 발생)
18
- const processedMessages = new Set();
19
- const DEDUP_TTL_MS = 60_000;
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
- async function setTypingStatus(client, channel, threadTs, status) {
33
- try {
34
- await client.assistant?.threads?.setStatus?.({
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
- if (processedMessages.has(messageTs)) {
47
- log.info('중복 메시지 무시', { messageTs });
48
- return;
49
- }
50
- processedMessages.add(messageTs);
51
- setTimeout(() => processedMessages.delete(messageTs), DEDUP_TTL_MS);
52
- // 허용된 사용자 확인
53
- if (allowedUsers.length > 0 && userId && !allowedUsers.includes(userId)) {
54
- log.warn('허용되지 않은 사용자', { userId });
55
- return;
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
- const promptParts = [memoryContext, slackContext, text].filter(Boolean);
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 || 'plan';
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 } = runAgent({
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
- activeKills.set(threadTs, kill);
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
- // session_id 캡처
142
- if (event.type === 'system' && event.subtype === 'init' && event.session_id) {
143
- newSessionId = event.session_id;
144
- }
145
- // 텍스트 응답 (assistant 이벤트는 전체 텍스트로 교체)
146
- if (event.type === 'assistant') {
147
- const extracted = event._fullText;
148
- if (extracted) {
149
- responseText = extracted;
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
- else if (event.errorType === 'session_corrupt') {
199
- log.info('session corrupt, 세션 초기화', { threadTs });
200
- clearSession(threadTs);
201
- responseText += `\n:warning: 세션 오류가 발생하여 세션을 초기화했습니다. 다시 메시지를 보내주세요.`;
160
+ break;
161
+ case 'turn-end':
162
+ if (event.turn?.result) {
163
+ responseText = event.turn.result;
202
164
  }
203
- else if (event.errorType === 'context_overflow' && !didAutoCompact) {
204
- didAutoCompact = true;
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
- else {
230
- responseText += `\n:x: ${errText}`;
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
- const preview = responseText.length > 3800
239
- ? responseText.slice(0, 3800) + '\n_... (처리 중)_'
240
- : responseText + '\n_... (처리 중)_';
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
- // 나머지 chunk는 별도 메시지
300
- for (let i = 1; i < chunks.length; 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 }).catch(() => { });
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 }).catch(() => { });
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
- activeKills.delete(threadTs);
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
- activeAgentCount--;
289
+ decrementActiveCount();
368
290
  }
369
291
  });
370
292
  }
371
- // 활성 승인 서버 추적 (채널/스레드별)
372
- const activeApprovalServers = new Map();
373
- function getApprovalKey(channel, threadTs) {
374
- return `${channel}:${threadTs}`;
375
- }
376
- function formatToolInput(toolName, toolInput) {
377
- if (toolName === 'Bash' && toolInput.command) {
378
- return `\`${String(toolInput.command).slice(0, 200)}\``;
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 (toolName === 'Write' && toolInput.file_path) {
381
- return `파일: \`${toolInput.file_path}\``;
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 (toolName === 'Edit' && toolInput.file_path) {
384
- return `파일: \`${toolInput.file_path}\``;
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
- if (toolName === 'NotebookEdit' && toolInput.notebook_path) {
387
- return `파일: \`${toolInput.notebook_path}\``;
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 = formatToolInput(request.toolName, request.toolInput);
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: ':white_check_mark: 승인' },
360
+ text: { type: 'plain_text', text: '이번만 승인' },
416
361
  style: 'primary',
417
362
  action_id: 'approve_tool',
418
- value: request.requestId,
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: ':x: 거부' },
373
+ text: { type: 'plain_text', text: '거부' },
423
374
  style: 'danger',
424
375
  action_id: 'deny_tool',
425
- value: request.requestId,
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
- // 도구 승인 action 핸들러
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 requestId = 'value' in action ? action.value : '';
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 requestId = 'value' in action ? action.value : '';
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: *거부됨* ${resolved ? '' : '(이미 처리됨)'}`,
586
+ text: `:x: *거부됨*${label} ${resolved ? '' : '(이미 처리됨)'}`,
491
587
  },
492
588
  },
493
589
  ],
494
590
  }).catch(() => { });
495
591
  }
496
592
  });
497
- // DM 메시지 핸들러 (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
- if (!isDM)
507
- return; // 채널 메시지는 app_mention에서만 처리
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
- slackContext = await fetchThreadContext(client, msg.channel, msg.thread_ts, msg.ts);
610
+ const ctx = await fetchThreadContext(client, msg.channel, msg.thread_ts, msg.ts);
611
+ slackContext = ctx.text;
612
+ threadFiles = ctx.files;
513
613
  }
514
- await handleIncoming(text, msg.channel, threadTs, msg.ts, client, msg.user, slackContext);
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
- if (!text)
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
- if (event.thread_ts) {
525
- // 스레드 멘션: 스레드 이전 대화 주입
526
- slackContext = await fetchThreadContext(client, event.channel, event.thread_ts, event.ts);
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
- await handleIncoming(text, event.channel, threadTs, event.ts, client, event.user, slackContext);
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