@pollit/twin-dev-bot 0.0.1
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/LICENSE +661 -0
- package/README.md +415 -0
- package/bin/twindevbot.js +22 -0
- package/dist/action-payload-store.d.ts +22 -0
- package/dist/action-payload-store.js +54 -0
- package/dist/active-runners.d.ts +44 -0
- package/dist/active-runners.js +114 -0
- package/dist/channel-store.d.ts +16 -0
- package/dist/channel-store.js +91 -0
- package/dist/claude/active-runners.d.ts +44 -0
- package/dist/claude/active-runners.js +114 -0
- package/dist/claude/claude-runner.d.ts +57 -0
- package/dist/claude/claude-runner.js +210 -0
- package/dist/claude/session-manager.d.ts +62 -0
- package/dist/claude/session-manager.js +247 -0
- package/dist/claude-runner.d.ts +57 -0
- package/dist/claude-runner.js +210 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +271 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.js +49 -0
- package/dist/conversation-store.d.ts +53 -0
- package/dist/conversation-store.js +173 -0
- package/dist/core/config.d.ts +9 -0
- package/dist/core/config.js +49 -0
- package/dist/core/logger.d.ts +34 -0
- package/dist/core/logger.js +110 -0
- package/dist/core/paths.d.ts +11 -0
- package/dist/core/paths.js +18 -0
- package/dist/core/platform.d.ts +18 -0
- package/dist/core/platform.js +33 -0
- package/dist/daemon/index.d.ts +3 -0
- package/dist/daemon/index.js +14 -0
- package/dist/daemon/macos.d.ts +8 -0
- package/dist/daemon/macos.js +150 -0
- package/dist/daemon/types.d.ts +9 -0
- package/dist/daemon/types.js +1 -0
- package/dist/daemon/windows.d.ts +8 -0
- package/dist/daemon/windows.js +137 -0
- package/dist/handlers/claude-command.d.ts +2 -0
- package/dist/handlers/claude-command.js +634 -0
- package/dist/handlers/claude-runner-setup.d.ts +16 -0
- package/dist/handlers/claude-runner-setup.js +445 -0
- package/dist/handlers/index.d.ts +3 -0
- package/dist/handlers/index.js +3 -0
- package/dist/handlers/init-handlers.d.ts +2 -0
- package/dist/handlers/init-handlers.js +189 -0
- package/dist/handlers/question-handlers.d.ts +2 -0
- package/dist/handlers/question-handlers.js +835 -0
- package/dist/i18n/en.d.ts +150 -0
- package/dist/i18n/en.js +163 -0
- package/dist/i18n/index.d.ts +20 -0
- package/dist/i18n/index.js +31 -0
- package/dist/i18n/ko.d.ts +1 -0
- package/dist/i18n/ko.js +141 -0
- package/dist/logger.d.ts +34 -0
- package/dist/logger.js +110 -0
- package/dist/multi-select-state.d.ts +58 -0
- package/dist/multi-select-state.js +151 -0
- package/dist/paths.d.ts +11 -0
- package/dist/paths.js +18 -0
- package/dist/pending-questions.d.ts +53 -0
- package/dist/pending-questions.js +139 -0
- package/dist/platform.d.ts +18 -0
- package/dist/platform.js +33 -0
- package/dist/progress-tracker.d.ts +47 -0
- package/dist/progress-tracker.js +218 -0
- package/dist/question-blocks.d.ts +27 -0
- package/dist/question-blocks.js +235 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +83 -0
- package/dist/session-manager.d.ts +62 -0
- package/dist/session-manager.js +247 -0
- package/dist/setup.d.ts +5 -0
- package/dist/setup.js +132 -0
- package/dist/slack/progress-tracker.d.ts +47 -0
- package/dist/slack/progress-tracker.js +218 -0
- package/dist/slack/question-blocks.d.ts +27 -0
- package/dist/slack/question-blocks.js +235 -0
- package/dist/stores/action-payload-store.d.ts +22 -0
- package/dist/stores/action-payload-store.js +54 -0
- package/dist/stores/channel-store.d.ts +16 -0
- package/dist/stores/channel-store.js +91 -0
- package/dist/stores/multi-select-state.d.ts +58 -0
- package/dist/stores/multi-select-state.js +151 -0
- package/dist/stores/pending-questions.d.ts +53 -0
- package/dist/stores/pending-questions.js +139 -0
- package/dist/stores/workspace-store.d.ts +27 -0
- package/dist/stores/workspace-store.js +160 -0
- package/dist/templates.d.ts +23 -0
- package/dist/templates.js +292 -0
- package/dist/types/claude-stream.d.ts +116 -0
- package/dist/types/claude-stream.js +3 -0
- package/dist/types/conversation.d.ts +16 -0
- package/dist/types/conversation.js +4 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.js +2 -0
- package/dist/types/slack.d.ts +51 -0
- package/dist/types/slack.js +1 -0
- package/dist/utils/display-width.d.ts +8 -0
- package/dist/utils/display-width.js +33 -0
- package/dist/utils/safe-async.d.ts +6 -0
- package/dist/utils/safe-async.js +14 -0
- package/dist/utils/slack-message.d.ts +73 -0
- package/dist/utils/slack-message.js +220 -0
- package/dist/utils/slack-rate-limit.d.ts +5 -0
- package/dist/utils/slack-rate-limit.js +49 -0
- package/dist/workspace-store.d.ts +27 -0
- package/dist/workspace-store.js +160 -0
- package/package.json +51 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { t } from "../i18n/index.js";
|
|
2
|
+
const SLACK_BLOCK_LIMIT = 50;
|
|
3
|
+
const SLACK_BUTTON_TEXT_LIMIT = 75;
|
|
4
|
+
/** 버튼 텍스트가 Slack 제한(75자)을 초과하면 말줄임표(…)를 붙여 잘린 것을 표시 */
|
|
5
|
+
function truncateButtonText(text) {
|
|
6
|
+
if (text.length <= SLACK_BUTTON_TEXT_LIMIT)
|
|
7
|
+
return text;
|
|
8
|
+
return text.slice(0, SLACK_BUTTON_TEXT_LIMIT - 1) + "…";
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* AskUserQuestion을 Slack Block으로 변환
|
|
12
|
+
* 단일 질문을 렌더링하고, 버튼 클릭 시 바로 Claude에 전달
|
|
13
|
+
*
|
|
14
|
+
* 주의: 버튼 value에는 짧은 필드만 포함.
|
|
15
|
+
* questionText, header, optionLabels 등은 action-payload-store에 별도 저장되어
|
|
16
|
+
* 핸들러에서 messageId로 조회함 (Slack action.value ~2,000바이트 제한 대응).
|
|
17
|
+
*/
|
|
18
|
+
export function buildQuestionBlocks(options) {
|
|
19
|
+
const { question, projectName, messageId, selectedAnswer, isSubmitted = false, selectedOptionIndexes = [], } = options;
|
|
20
|
+
const blocks = [];
|
|
21
|
+
// 헤더
|
|
22
|
+
blocks.push({
|
|
23
|
+
type: "header",
|
|
24
|
+
text: {
|
|
25
|
+
type: "plain_text",
|
|
26
|
+
text: isSubmitted ? t("question.headerCompleted") : t("question.header"),
|
|
27
|
+
emoji: true,
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
// 질문 제목
|
|
31
|
+
if (question.header) {
|
|
32
|
+
blocks.push({
|
|
33
|
+
type: "section",
|
|
34
|
+
text: { type: "mrkdwn", text: `*${question.header}*` },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
// 질문 내용
|
|
38
|
+
blocks.push({
|
|
39
|
+
type: "section",
|
|
40
|
+
text: { type: "mrkdwn", text: question.question },
|
|
41
|
+
});
|
|
42
|
+
blocks.push({ type: "divider" });
|
|
43
|
+
if (isSubmitted && selectedAnswer) {
|
|
44
|
+
// 완료 상태 - 체크 이모지와 선택된 답변 표시
|
|
45
|
+
blocks.push({
|
|
46
|
+
type: "section",
|
|
47
|
+
text: { type: "mrkdwn", text: `✅ *${selectedAnswer}*` },
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
else if (question.multiSelect) {
|
|
51
|
+
// 복수 선택 모드 - 토글 버튼 + 선택 완료 버튼
|
|
52
|
+
const selectedSet = new Set(selectedOptionIndexes);
|
|
53
|
+
if (question.options && question.options.length > 0) {
|
|
54
|
+
// Slack 블록 50개 제한 대응: 옵션 렌더링 전략 결정
|
|
55
|
+
const trailingBlocks = 3; // submit + textInput + hint
|
|
56
|
+
const availableForOptions = SLACK_BLOCK_LIMIT - blocks.length - trailingBlocks;
|
|
57
|
+
const descriptionCount = question.options.filter(o => o.description).length;
|
|
58
|
+
const totalWithDesc = question.options.length + descriptionCount;
|
|
59
|
+
let skipDescriptions = false;
|
|
60
|
+
let optionsToRender = question.options;
|
|
61
|
+
if (totalWithDesc > availableForOptions) {
|
|
62
|
+
skipDescriptions = true;
|
|
63
|
+
if (question.options.length > availableForOptions) {
|
|
64
|
+
// 잘림 안내 context 블록 1개를 위해 -1
|
|
65
|
+
const maxOptions = Math.max(1, availableForOptions - 1);
|
|
66
|
+
optionsToRender = question.options.slice(0, maxOptions);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const isTruncated = optionsToRender.length < question.options.length;
|
|
70
|
+
optionsToRender.forEach((opt, i) => {
|
|
71
|
+
const isSelected = selectedSet.has(i);
|
|
72
|
+
const buttonText = isSelected ? `✅ ${opt.label}` : opt.label;
|
|
73
|
+
const toggleValue = {
|
|
74
|
+
questionIndex: 0,
|
|
75
|
+
optionIndex: i,
|
|
76
|
+
label: opt.label,
|
|
77
|
+
projectName,
|
|
78
|
+
messageId,
|
|
79
|
+
};
|
|
80
|
+
const button = {
|
|
81
|
+
type: "button",
|
|
82
|
+
text: { type: "plain_text", text: truncateButtonText(buttonText), emoji: true },
|
|
83
|
+
value: JSON.stringify(toggleValue),
|
|
84
|
+
action_id: `toggle_option_0_${i}`,
|
|
85
|
+
style: isSelected ? "primary" : undefined,
|
|
86
|
+
};
|
|
87
|
+
blocks.push({
|
|
88
|
+
type: "actions",
|
|
89
|
+
elements: [button],
|
|
90
|
+
});
|
|
91
|
+
// 설명이 있으면 버튼 아래에 표시 (블록 제한 시 생략)
|
|
92
|
+
if (!skipDescriptions && opt.description) {
|
|
93
|
+
blocks.push({
|
|
94
|
+
type: "context",
|
|
95
|
+
elements: [{ type: "plain_text", text: opt.description, emoji: false }],
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
if (isTruncated) {
|
|
100
|
+
blocks.push({
|
|
101
|
+
type: "context",
|
|
102
|
+
elements: [{ type: "mrkdwn", text: t("question.truncatedOptions", { shown: String(optionsToRender.length), total: String(question.options.length) }) }],
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// 선택 완료 버튼
|
|
107
|
+
const submitValue = {
|
|
108
|
+
questionIndex: 0,
|
|
109
|
+
projectName,
|
|
110
|
+
messageId,
|
|
111
|
+
};
|
|
112
|
+
blocks.push({
|
|
113
|
+
type: "actions",
|
|
114
|
+
block_id: "submit_multi_select_0",
|
|
115
|
+
elements: [
|
|
116
|
+
{
|
|
117
|
+
type: "button",
|
|
118
|
+
text: { type: "plain_text", text: t("question.submitSelection"), emoji: true },
|
|
119
|
+
value: JSON.stringify(submitValue),
|
|
120
|
+
action_id: "submit_multi_select_0",
|
|
121
|
+
style: "primary",
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
});
|
|
125
|
+
// 직접 입력 버튼 (멀티 선택에서도 직접 입력 가능)
|
|
126
|
+
const multiSelectTextInputValue = {
|
|
127
|
+
questionIndex: 0,
|
|
128
|
+
type: "text_input",
|
|
129
|
+
projectName,
|
|
130
|
+
messageId,
|
|
131
|
+
};
|
|
132
|
+
blocks.push({
|
|
133
|
+
type: "actions",
|
|
134
|
+
block_id: "text_input_0",
|
|
135
|
+
elements: [
|
|
136
|
+
{
|
|
137
|
+
type: "button",
|
|
138
|
+
text: { type: "plain_text", text: t("question.textInput"), emoji: true },
|
|
139
|
+
value: JSON.stringify(multiSelectTextInputValue),
|
|
140
|
+
action_id: "text_input_0",
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
});
|
|
144
|
+
// 현재 선택된 항목 안내
|
|
145
|
+
if (selectedOptionIndexes.length > 0) {
|
|
146
|
+
const selectedLabels = selectedOptionIndexes
|
|
147
|
+
.map(i => question.options[i]?.label)
|
|
148
|
+
.filter(Boolean)
|
|
149
|
+
.join(", ");
|
|
150
|
+
blocks.push({
|
|
151
|
+
type: "context",
|
|
152
|
+
elements: [{ type: "mrkdwn", text: t("question.currentSelection", { labels: selectedLabels }) }],
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
blocks.push({
|
|
157
|
+
type: "context",
|
|
158
|
+
elements: [{ type: "mrkdwn", text: t("question.selectHint") }],
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
// 단일 선택 모드 - 기존 로직
|
|
164
|
+
if (question.options && question.options.length > 0) {
|
|
165
|
+
// Slack 블록 50개 제한 대응: 옵션 렌더링 전략 결정
|
|
166
|
+
const trailingBlocks = 1; // textInput
|
|
167
|
+
const availableForOptions = SLACK_BLOCK_LIMIT - blocks.length - trailingBlocks;
|
|
168
|
+
const descriptionCount = question.options.filter(o => o.description).length;
|
|
169
|
+
const totalWithDesc = question.options.length + descriptionCount;
|
|
170
|
+
let skipDescriptions = false;
|
|
171
|
+
let optionsToRender = question.options;
|
|
172
|
+
if (totalWithDesc > availableForOptions) {
|
|
173
|
+
skipDescriptions = true;
|
|
174
|
+
if (question.options.length > availableForOptions) {
|
|
175
|
+
const maxOptions = Math.max(1, availableForOptions - 1);
|
|
176
|
+
optionsToRender = question.options.slice(0, maxOptions);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const isTruncated = optionsToRender.length < question.options.length;
|
|
180
|
+
optionsToRender.forEach((opt, i) => {
|
|
181
|
+
const buttonValue = {
|
|
182
|
+
questionIndex: 0,
|
|
183
|
+
optionIndex: i,
|
|
184
|
+
label: opt.label,
|
|
185
|
+
isMultiSelect: false,
|
|
186
|
+
projectName,
|
|
187
|
+
messageId,
|
|
188
|
+
};
|
|
189
|
+
const button = {
|
|
190
|
+
type: "button",
|
|
191
|
+
text: { type: "plain_text", text: truncateButtonText(opt.label), emoji: true },
|
|
192
|
+
value: JSON.stringify(buttonValue),
|
|
193
|
+
action_id: `select_option_0_${i}`,
|
|
194
|
+
};
|
|
195
|
+
blocks.push({
|
|
196
|
+
type: "actions",
|
|
197
|
+
elements: [button],
|
|
198
|
+
});
|
|
199
|
+
// 설명이 있으면 버튼 아래에 표시 (블록 제한 시 생략)
|
|
200
|
+
if (!skipDescriptions && opt.description) {
|
|
201
|
+
blocks.push({
|
|
202
|
+
type: "context",
|
|
203
|
+
elements: [{ type: "plain_text", text: opt.description, emoji: false }],
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
if (isTruncated) {
|
|
208
|
+
blocks.push({
|
|
209
|
+
type: "context",
|
|
210
|
+
elements: [{ type: "mrkdwn", text: t("question.truncatedOptions", { shown: String(optionsToRender.length), total: String(question.options.length) }) }],
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// 직접 입력 버튼
|
|
215
|
+
const textInputValue = {
|
|
216
|
+
questionIndex: 0,
|
|
217
|
+
type: "text_input",
|
|
218
|
+
projectName,
|
|
219
|
+
messageId,
|
|
220
|
+
};
|
|
221
|
+
blocks.push({
|
|
222
|
+
type: "actions",
|
|
223
|
+
block_id: "text_input_0",
|
|
224
|
+
elements: [
|
|
225
|
+
{
|
|
226
|
+
type: "button",
|
|
227
|
+
text: { type: "plain_text", text: t("question.textInput"), emoji: true },
|
|
228
|
+
value: JSON.stringify(textInputValue),
|
|
229
|
+
action_id: "text_input_0",
|
|
230
|
+
},
|
|
231
|
+
],
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
return blocks;
|
|
235
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slack action.value (~2,000 bytes) 및 private_metadata (~3,000 bytes) 크기 제한 대응.
|
|
3
|
+
* 큰 페이로드를 서버 사이드에 저장하고 짧은 키만 버튼 값에 포함.
|
|
4
|
+
* TTL 기반 자동 정리로 메모리 누수 방지.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* 페이로드 저장
|
|
8
|
+
*/
|
|
9
|
+
export declare function setPayload(key: string, data: unknown, ttlMs?: number): void;
|
|
10
|
+
/**
|
|
11
|
+
* 페이로드 조회 (기본: 조회 후 삭제하지 않음)
|
|
12
|
+
* @param remove true이면 조회 후 삭제 (일회성 데이터용)
|
|
13
|
+
*/
|
|
14
|
+
export declare function getPayload<T>(key: string, remove?: boolean): T | null;
|
|
15
|
+
/**
|
|
16
|
+
* 페이로드 삭제
|
|
17
|
+
*/
|
|
18
|
+
export declare function removePayload(key: string): void;
|
|
19
|
+
/**
|
|
20
|
+
* 테스트용: 스토어 전체 초기화
|
|
21
|
+
*/
|
|
22
|
+
export declare function clearAllPayloads(): void;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slack action.value (~2,000 bytes) 및 private_metadata (~3,000 bytes) 크기 제한 대응.
|
|
3
|
+
* 큰 페이로드를 서버 사이드에 저장하고 짧은 키만 버튼 값에 포함.
|
|
4
|
+
* TTL 기반 자동 정리로 메모리 누수 방지.
|
|
5
|
+
*/
|
|
6
|
+
const store = new Map();
|
|
7
|
+
const DEFAULT_TTL_MS = 2 * 60 * 60 * 1000; // 2시간
|
|
8
|
+
function cleanup() {
|
|
9
|
+
const now = Date.now();
|
|
10
|
+
for (const [key, entry] of store) {
|
|
11
|
+
if (now > entry.expiresAt)
|
|
12
|
+
store.delete(key);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* 페이로드 저장
|
|
17
|
+
*/
|
|
18
|
+
export function setPayload(key, data, ttlMs = DEFAULT_TTL_MS) {
|
|
19
|
+
cleanup();
|
|
20
|
+
store.set(key, { data, expiresAt: Date.now() + ttlMs });
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 페이로드 조회 (기본: 조회 후 삭제하지 않음)
|
|
24
|
+
* @param remove true이면 조회 후 삭제 (일회성 데이터용)
|
|
25
|
+
*/
|
|
26
|
+
export function getPayload(key, remove = false) {
|
|
27
|
+
const entry = store.get(key);
|
|
28
|
+
if (!entry)
|
|
29
|
+
return null;
|
|
30
|
+
if (Date.now() > entry.expiresAt) {
|
|
31
|
+
store.delete(key);
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
if (remove) {
|
|
35
|
+
store.delete(key);
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
// TTL 갱신: 접근 시 만료 시간 리셋 (사용자가 상호작용 중임을 표시)
|
|
39
|
+
entry.expiresAt = Date.now() + DEFAULT_TTL_MS;
|
|
40
|
+
}
|
|
41
|
+
return entry.data;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* 페이로드 삭제
|
|
45
|
+
*/
|
|
46
|
+
export function removePayload(key) {
|
|
47
|
+
store.delete(key);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* 테스트용: 스토어 전체 초기화
|
|
51
|
+
*/
|
|
52
|
+
export function clearAllPayloads() {
|
|
53
|
+
store.clear();
|
|
54
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel Store
|
|
3
|
+
*
|
|
4
|
+
* 슬랙 채널과 작업 디렉토리의 매핑을 관리합니다.
|
|
5
|
+
* /twindevbot init으로 설정된 채널별 작업 디렉토리를 영속적으로 저장합니다.
|
|
6
|
+
*
|
|
7
|
+
* 키: channelId (Slack 채널 ID)
|
|
8
|
+
* 값: { directory, projectName }
|
|
9
|
+
*/
|
|
10
|
+
export interface ChannelDir {
|
|
11
|
+
directory: string;
|
|
12
|
+
projectName: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function setChannelDir(channelId: string, dir: ChannelDir): void;
|
|
15
|
+
export declare function getChannelDir(channelId: string): ChannelDir | undefined;
|
|
16
|
+
export declare function removeChannelDir(channelId: string): void;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel Store
|
|
3
|
+
*
|
|
4
|
+
* 슬랙 채널과 작업 디렉토리의 매핑을 관리합니다.
|
|
5
|
+
* /twindevbot init으로 설정된 채널별 작업 디렉토리를 영속적으로 저장합니다.
|
|
6
|
+
*
|
|
7
|
+
* 키: channelId (Slack 채널 ID)
|
|
8
|
+
* 값: { directory, projectName }
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync, renameSync } from "fs";
|
|
11
|
+
import { createLogger } from "../core/logger.js";
|
|
12
|
+
import { CHANNELS_FILE } from "../core/paths.js";
|
|
13
|
+
const log = createLogger("channel-store");
|
|
14
|
+
const channels = new Map();
|
|
15
|
+
function loadFromFile() {
|
|
16
|
+
try {
|
|
17
|
+
if (!existsSync(CHANNELS_FILE)) {
|
|
18
|
+
log.info("No channels file found, starting fresh");
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const content = readFileSync(CHANNELS_FILE, "utf-8");
|
|
22
|
+
let data;
|
|
23
|
+
try {
|
|
24
|
+
data = JSON.parse(content);
|
|
25
|
+
}
|
|
26
|
+
catch (parseError) {
|
|
27
|
+
log.error("Channels file is corrupted, starting fresh", { parseError });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (!data.channels || !Array.isArray(data.channels)) {
|
|
31
|
+
log.error("Channels file has invalid structure, starting fresh");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
let loadedCount = 0;
|
|
35
|
+
for (const c of data.channels) {
|
|
36
|
+
try {
|
|
37
|
+
channels.set(c.channelId, {
|
|
38
|
+
directory: c.directory,
|
|
39
|
+
projectName: c.projectName,
|
|
40
|
+
});
|
|
41
|
+
loadedCount++;
|
|
42
|
+
}
|
|
43
|
+
catch (entryError) {
|
|
44
|
+
log.warn("Skipping invalid channel entry", { entry: c, error: entryError });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
log.info("Channels loaded from file", { count: loadedCount, total: data.channels.length });
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
log.error("Failed to load channels from file", { error });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function saveToFile() {
|
|
54
|
+
try {
|
|
55
|
+
const serialized = Array.from(channels.entries()).map(([channelId, c]) => ({
|
|
56
|
+
channelId,
|
|
57
|
+
directory: c.directory,
|
|
58
|
+
projectName: c.projectName,
|
|
59
|
+
}));
|
|
60
|
+
const data = {
|
|
61
|
+
version: 1,
|
|
62
|
+
channels: serialized,
|
|
63
|
+
};
|
|
64
|
+
const tmpFile = CHANNELS_FILE + ".tmp";
|
|
65
|
+
writeFileSync(tmpFile, JSON.stringify(data, null, 2));
|
|
66
|
+
renameSync(tmpFile, CHANNELS_FILE);
|
|
67
|
+
log.debug("Channels saved to file", { count: serialized.length });
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
log.error("Failed to save channels to file", { error });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// 모듈 로드 시 파일에서 복원
|
|
74
|
+
loadFromFile();
|
|
75
|
+
export function setChannelDir(channelId, dir) {
|
|
76
|
+
channels.set(channelId, dir);
|
|
77
|
+
log.info("Channel directory set", {
|
|
78
|
+
channelId,
|
|
79
|
+
projectName: dir.projectName,
|
|
80
|
+
directory: dir.directory,
|
|
81
|
+
});
|
|
82
|
+
saveToFile();
|
|
83
|
+
}
|
|
84
|
+
export function getChannelDir(channelId) {
|
|
85
|
+
return channels.get(channelId);
|
|
86
|
+
}
|
|
87
|
+
export function removeChannelDir(channelId) {
|
|
88
|
+
channels.delete(channelId);
|
|
89
|
+
log.debug("Channel directory removed", { channelId });
|
|
90
|
+
saveToFile();
|
|
91
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 복수 선택 상태 관리
|
|
3
|
+
*
|
|
4
|
+
* multiSelect가 true인 질문에서 사용자가 선택한 옵션들을
|
|
5
|
+
* "선택 완료" 버튼을 누르기 전까지 임시 저장합니다.
|
|
6
|
+
*
|
|
7
|
+
* 키: `${projectName}:${messageId}`
|
|
8
|
+
* 값: { selected: 선택된 옵션 인덱스 Set, options: 전체 옵션 배열 }
|
|
9
|
+
*/
|
|
10
|
+
import type { QuestionOption } from "../types/conversation.js";
|
|
11
|
+
interface MultiSelectState {
|
|
12
|
+
selected: Set<number>;
|
|
13
|
+
options: QuestionOption[];
|
|
14
|
+
questionText: string;
|
|
15
|
+
header?: string;
|
|
16
|
+
createdAt: number;
|
|
17
|
+
}
|
|
18
|
+
interface InitStateOptions {
|
|
19
|
+
projectName: string;
|
|
20
|
+
messageId: string;
|
|
21
|
+
options: QuestionOption[];
|
|
22
|
+
questionText: string;
|
|
23
|
+
header?: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* 상태 초기화 (질문 생성 시 호출)
|
|
27
|
+
*/
|
|
28
|
+
export declare function initState(opts: InitStateOptions): void;
|
|
29
|
+
/**
|
|
30
|
+
* 옵션 토글 (선택/해제)
|
|
31
|
+
* @returns 토글 후 선택 여부
|
|
32
|
+
*/
|
|
33
|
+
export declare function toggleOption(projectName: string, messageId: string, optionIndex: number): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* 선택된 옵션 인덱스 배열 조회
|
|
36
|
+
*/
|
|
37
|
+
export declare function getSelectedOptions(projectName: string, messageId: string): number[];
|
|
38
|
+
/**
|
|
39
|
+
* 전체 상태 조회 (옵션 정보 포함)
|
|
40
|
+
*/
|
|
41
|
+
export declare function getState(projectName: string, messageId: string): MultiSelectState | null;
|
|
42
|
+
/**
|
|
43
|
+
* 특정 옵션이 선택되었는지 확인
|
|
44
|
+
*/
|
|
45
|
+
export declare function isOptionSelected(projectName: string, messageId: string, optionIndex: number): boolean;
|
|
46
|
+
/**
|
|
47
|
+
* 상태 삭제 (완료 후 정리용)
|
|
48
|
+
*/
|
|
49
|
+
export declare function clearState(projectName: string, messageId: string): void;
|
|
50
|
+
/**
|
|
51
|
+
* 정리 타이머 중지 (주로 테스트용)
|
|
52
|
+
*/
|
|
53
|
+
export declare function stopCleanupTimer(): void;
|
|
54
|
+
/**
|
|
55
|
+
* 테스트용: cleanup 수동 실행
|
|
56
|
+
*/
|
|
57
|
+
export declare function runCleanup(): number;
|
|
58
|
+
export {};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 복수 선택 상태 관리
|
|
3
|
+
*
|
|
4
|
+
* multiSelect가 true인 질문에서 사용자가 선택한 옵션들을
|
|
5
|
+
* "선택 완료" 버튼을 누르기 전까지 임시 저장합니다.
|
|
6
|
+
*
|
|
7
|
+
* 키: `${projectName}:${messageId}`
|
|
8
|
+
* 값: { selected: 선택된 옵션 인덱스 Set, options: 전체 옵션 배열 }
|
|
9
|
+
*/
|
|
10
|
+
import { createLogger } from "../core/logger.js";
|
|
11
|
+
const log = createLogger("multi-select-state");
|
|
12
|
+
/** 선택 상태 저장소 */
|
|
13
|
+
const stateMap = new Map();
|
|
14
|
+
// 1시간이 지난 상태는 자동 삭제 (multi-select는 오래 걸리지 않음)
|
|
15
|
+
const MAX_AGE_MS = 60 * 60 * 1000;
|
|
16
|
+
let cleanupInterval = null;
|
|
17
|
+
/**
|
|
18
|
+
* 키 생성 헬퍼
|
|
19
|
+
*/
|
|
20
|
+
function makeKey(projectName, messageId) {
|
|
21
|
+
return `${projectName}:${messageId}`;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* 상태 초기화 (질문 생성 시 호출)
|
|
25
|
+
*/
|
|
26
|
+
export function initState(opts) {
|
|
27
|
+
const key = makeKey(opts.projectName, opts.messageId);
|
|
28
|
+
if (stateMap.has(key)) {
|
|
29
|
+
log.debug("State already exists, skipping init", { key });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
stateMap.set(key, {
|
|
33
|
+
selected: new Set(),
|
|
34
|
+
options: opts.options,
|
|
35
|
+
questionText: opts.questionText,
|
|
36
|
+
header: opts.header,
|
|
37
|
+
createdAt: Date.now(),
|
|
38
|
+
});
|
|
39
|
+
log.debug("State initialized", { key, optionCount: opts.options.length });
|
|
40
|
+
// 타이머가 없으면 시작
|
|
41
|
+
ensureCleanupTimer();
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* 옵션 토글 (선택/해제)
|
|
45
|
+
* @returns 토글 후 선택 여부
|
|
46
|
+
*/
|
|
47
|
+
export function toggleOption(projectName, messageId, optionIndex) {
|
|
48
|
+
const key = makeKey(projectName, messageId);
|
|
49
|
+
const state = stateMap.get(key);
|
|
50
|
+
if (!state) {
|
|
51
|
+
log.warn("State not found for toggle", { key });
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
if (state.selected.has(optionIndex)) {
|
|
55
|
+
state.selected.delete(optionIndex);
|
|
56
|
+
log.debug("Option deselected", { projectName, messageId, optionIndex });
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
state.selected.add(optionIndex);
|
|
61
|
+
log.debug("Option selected", { projectName, messageId, optionIndex });
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* 선택된 옵션 인덱스 배열 조회
|
|
67
|
+
*/
|
|
68
|
+
export function getSelectedOptions(projectName, messageId) {
|
|
69
|
+
const key = makeKey(projectName, messageId);
|
|
70
|
+
const state = stateMap.get(key);
|
|
71
|
+
if (!state) {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
return Array.from(state.selected).sort((a, b) => a - b);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* 전체 상태 조회 (옵션 정보 포함)
|
|
78
|
+
*/
|
|
79
|
+
export function getState(projectName, messageId) {
|
|
80
|
+
const key = makeKey(projectName, messageId);
|
|
81
|
+
return stateMap.get(key) ?? null;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* 특정 옵션이 선택되었는지 확인
|
|
85
|
+
*/
|
|
86
|
+
export function isOptionSelected(projectName, messageId, optionIndex) {
|
|
87
|
+
const key = makeKey(projectName, messageId);
|
|
88
|
+
const state = stateMap.get(key);
|
|
89
|
+
return state?.selected.has(optionIndex) ?? false;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* 상태 삭제 (완료 후 정리용)
|
|
93
|
+
*/
|
|
94
|
+
export function clearState(projectName, messageId) {
|
|
95
|
+
const key = makeKey(projectName, messageId);
|
|
96
|
+
stateMap.delete(key);
|
|
97
|
+
log.debug("State cleared", { projectName, messageId });
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* 오래된 상태 정리
|
|
101
|
+
*/
|
|
102
|
+
function cleanup() {
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
let cleanedCount = 0;
|
|
105
|
+
for (const [key, state] of stateMap.entries()) {
|
|
106
|
+
const ageMs = now - state.createdAt;
|
|
107
|
+
if (ageMs > MAX_AGE_MS) {
|
|
108
|
+
stateMap.delete(key);
|
|
109
|
+
cleanedCount++;
|
|
110
|
+
log.info("Multi-select state expired", {
|
|
111
|
+
key,
|
|
112
|
+
ageMinutes: Math.floor(ageMs / (60 * 1000)),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (cleanedCount > 0) {
|
|
117
|
+
log.info("Multi-select cleanup completed", { cleanedCount, remaining: stateMap.size });
|
|
118
|
+
}
|
|
119
|
+
return cleanedCount;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* 정리 타이머 시작
|
|
123
|
+
*/
|
|
124
|
+
function ensureCleanupTimer() {
|
|
125
|
+
if (cleanupInterval)
|
|
126
|
+
return;
|
|
127
|
+
// 즉시 한 번 실행
|
|
128
|
+
cleanup();
|
|
129
|
+
// 매 시간마다 실행
|
|
130
|
+
cleanupInterval = setInterval(() => {
|
|
131
|
+
cleanup();
|
|
132
|
+
}, 60 * 60 * 1000);
|
|
133
|
+
cleanupInterval.unref();
|
|
134
|
+
log.debug("Multi-select cleanup timer started (runs every hour)");
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* 정리 타이머 중지 (주로 테스트용)
|
|
138
|
+
*/
|
|
139
|
+
export function stopCleanupTimer() {
|
|
140
|
+
if (cleanupInterval) {
|
|
141
|
+
clearInterval(cleanupInterval);
|
|
142
|
+
cleanupInterval = null;
|
|
143
|
+
log.debug("Multi-select cleanup timer stopped");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* 테스트용: cleanup 수동 실행
|
|
148
|
+
*/
|
|
149
|
+
export function runCleanup() {
|
|
150
|
+
return cleanup();
|
|
151
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 복수 질문 배치 관리
|
|
3
|
+
*
|
|
4
|
+
* Claude의 AskUserQuestion이 여러 질문을 동시에 보낼 때,
|
|
5
|
+
* Slack에서는 한 번에 하나씩 표시하고 모든 답변을 수집한 후
|
|
6
|
+
* 조합하여 Claude에 전달합니다.
|
|
7
|
+
*
|
|
8
|
+
* 키: threadTs (스레드당 하나의 배치)
|
|
9
|
+
*/
|
|
10
|
+
import type { Question } from "../types/conversation.js";
|
|
11
|
+
export interface PendingQuestionBatch {
|
|
12
|
+
questions: Question[];
|
|
13
|
+
answers: string[];
|
|
14
|
+
currentIndex: number;
|
|
15
|
+
projectName: string;
|
|
16
|
+
channelId: string;
|
|
17
|
+
createdAt: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* 배치 초기화 (questions.length > 1일 때 호출)
|
|
21
|
+
*/
|
|
22
|
+
export declare function initPendingBatch(threadTs: string, questions: Question[], projectName: string, channelId: string): void;
|
|
23
|
+
/**
|
|
24
|
+
* 대기 중인 배치 존재 여부
|
|
25
|
+
*/
|
|
26
|
+
export declare function hasPendingBatch(threadTs: string): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* 답변 기록 후 다음 질문 반환.
|
|
29
|
+
* done=true이면 모든 질문에 답변 완료.
|
|
30
|
+
*/
|
|
31
|
+
export declare function recordAnswerAndAdvance(threadTs: string, answer: string): {
|
|
32
|
+
done: boolean;
|
|
33
|
+
nextQuestion?: Question;
|
|
34
|
+
batch: PendingQuestionBatch;
|
|
35
|
+
} | null;
|
|
36
|
+
/**
|
|
37
|
+
* 모든 답변을 조합하여 Claude resume용 문자열 생성.
|
|
38
|
+
* 형식: "[header]: answer" (줄바꿈 구분)
|
|
39
|
+
* header가 없으면 질문 텍스트 앞 50자 사용.
|
|
40
|
+
*/
|
|
41
|
+
export declare function buildCombinedAnswer(threadTs: string): string | null;
|
|
42
|
+
/**
|
|
43
|
+
* 배치 정리
|
|
44
|
+
*/
|
|
45
|
+
export declare function clearPendingBatch(threadTs: string): void;
|
|
46
|
+
/**
|
|
47
|
+
* 정리 타이머 중지 (주로 테스트용)
|
|
48
|
+
*/
|
|
49
|
+
export declare function stopCleanupTimer(): void;
|
|
50
|
+
/**
|
|
51
|
+
* 테스트용: cleanup 수동 실행
|
|
52
|
+
*/
|
|
53
|
+
export declare function runCleanup(): number;
|